sbxloop 0.5.7__tar.gz → 0.5.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {sbxloop-0.5.7 → sbxloop-0.5.9}/PKG-INFO +1 -1
  2. sbxloop-0.5.7/src/sbxloop/_vendor/sbxloop_worker-0.5.7-py3-none-any.whl → sbxloop-0.5.9/src/sbxloop/_vendor/sbxloop_worker-0.5.9-py3-none-any.whl +0 -0
  3. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/_version.py +2 -2
  4. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/app.py +63 -0
  5. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/tui.py +7 -2
  6. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/config.py +30 -0
  7. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/engine.py +20 -5
  8. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/model.py +29 -1
  9. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/phases.py +39 -4
  10. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/execute.md +7 -5
  11. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/plan.md +12 -2
  12. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/events.py +2 -0
  13. sbxloop-0.5.9/src/sbxloop/policy.py +167 -0
  14. {sbxloop-0.5.7 → sbxloop-0.5.9}/.gitignore +0 -0
  15. {sbxloop-0.5.7 → sbxloop-0.5.9}/README.md +0 -0
  16. {sbxloop-0.5.7 → sbxloop-0.5.9}/hatch_build.py +0 -0
  17. {sbxloop-0.5.7 → sbxloop-0.5.9}/pyproject.toml +0 -0
  18. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/__init__.py +0 -0
  19. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/__init__.py +0 -0
  20. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/doctor.py +0 -0
  21. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/deliver.py +0 -0
  22. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/__init__.py +0 -0
  23. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/__init__.py +0 -0
  24. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/decompose.md +0 -0
  25. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
  26. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/validate.md +0 -0
  27. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/store.py +0 -0
  28. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/errors.py +0 -0
  29. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/gh/__init__.py +0 -0
  30. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/gh/ops.py +0 -0
  31. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/gh/reporter.py +0 -0
  32. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/ids.py +0 -0
  33. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/py.typed +0 -0
  34. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/__init__.py +0 -0
  35. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/cli.py +0 -0
  36. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/models.py +0 -0
  37. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/pair.py +0 -0
  38. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/parse.py +0 -0
  39. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/provision.py +0 -0
  40. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/prune.py +0 -0
  41. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/sandbox.py +0 -0
  42. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/worker/__init__.py +0 -0
  43. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/worker/client.py +0 -0
  44. {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/worker/wheel.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sbxloop
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary: Agentic loop orchestration on Docker Sandboxes (sbx) with isolated credential domains
5
5
  Project-URL: Homepage, https://github.com/brettbergin/sbxloop
6
6
  Project-URL: Repository, https://github.com/brettbergin/sbxloop
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.5.7'
22
- __version_tuple__ = version_tuple = (0, 5, 7)
21
+ __version__ = version = '0.5.9'
22
+ __version_tuple__ = version_tuple = (0, 5, 9)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -587,6 +587,56 @@ def config_show() -> None:
587
587
  console.print(table)
588
588
 
589
589
 
590
+ @config_app.command("policy")
591
+ def config_policy() -> None:
592
+ """Show the effective per-phase network egress policy."""
593
+ from sbxloop.policy import PROMPT_ADVERTISED_DOMAINS
594
+ from sbxloop.sbx.provision import AGENT_ALLOW_DOMAINS, GITHUB_ALLOW_DOMAINS
595
+
596
+ try:
597
+ config = load_config()
598
+ except SdxloopError as exc:
599
+ console.print(f"[bold red]{exc}[/]")
600
+ raise typer.Exit(2) from exc
601
+
602
+ extra = list(config.sandbox.extra_allow_domains)
603
+ baseline = ", ".join([*AGENT_ALLOW_DOMAINS, *extra])
604
+ advertised = ", ".join(PROMPT_ADVERTISED_DOMAINS)
605
+
606
+ table = Table(title="agent sandbox: effective egress per phase")
607
+ table.add_column("phase", no_wrap=True)
608
+ table.add_column("policy", overflow="fold")
609
+ table.add_row("decompose / plan", "baseline")
610
+ table.add_row(
611
+ "execute",
612
+ "baseline + plan-declared grants (auto-granted just before execute, "
613
+ "within the [policy] bounds below; every grant/refusal is event-logged)",
614
+ )
615
+ table.add_row(
616
+ "scrutinize / verify / validate",
617
+ "baseline + grants already made — sbx has no policy revocation, so "
618
+ "grants persist for the sandbox's lifetime (sandboxes are removed at "
619
+ "run end; grants never outlive a run)",
620
+ )
621
+ console.print(table)
622
+ console.print(f"baseline (provisioned per-sandbox): {baseline}")
623
+ console.print(f"advertised by the user's balanced preset: {advertised}")
624
+
625
+ bounds = Table(title="[policy] bounds for plan-declared grants")
626
+ bounds.add_column("bound", no_wrap=True)
627
+ bounds.add_column("patterns", overflow="fold")
628
+ bounds.add_row(
629
+ "allow", ", ".join(config.policy.allow) or "(empty — plans may only use the baseline)"
630
+ )
631
+ bounds.add_row("deny", ", ".join(config.policy.deny) or "(none)")
632
+ console.print(bounds)
633
+
634
+ if config.github.enabled:
635
+ gh_domains = ", ".join([*GITHUB_ALLOW_DOMAINS, *extra])
636
+ console.print(f"github sandbox (all phases, no plan grants): {gh_domains}")
637
+ console.print("audit trail: [cyan]sbxloop logs RUN_ID --type policy.[/]")
638
+
639
+
590
640
  @app.command()
591
641
  def init(
592
642
  force: Annotated[bool, typer.Option("--force", help="Overwrite an existing file.")] = False,
@@ -635,6 +685,19 @@ secret_strategy = "proxy"
635
685
  # Extra network allow rules applied to both sandboxes.
636
686
  extra_allow_domains = []
637
687
 
688
+ [policy]
689
+ # Bounds for plan-declared egress. At PLAN time the agent may declare extra
690
+ # domains a task needs during EXECUTE (each with a justification); they are
691
+ # auto-granted to the agent sandbox just before EXECUTE only when they match
692
+ # `allow` and no `deny` pattern, and every grant/refusal is logged as a run
693
+ # event (`sbxloop logs RUN --type policy.`). Patterns: exact domains,
694
+ # "*.example.com" (the domain and all subdomains), or "*" (everything).
695
+ # Empty `allow` (the default) means plans may only use the always-reachable
696
+ # baseline: the Copilot/GitHub hosts, PyPI, and apt mirrors.
697
+ # See `sbxloop config policy` for the effective per-phase policy.
698
+ allow = []
699
+ deny = []
700
+
638
701
  [github]
639
702
  # The GitHub integration. Unset (the default) disables GitHub entirely:
640
703
  # no github sandbox is provisioned, GH_TOKEN is not required, and
@@ -39,7 +39,7 @@ TASK_STATE_STYLES = {
39
39
  }
40
40
 
41
41
  # Compact one-liner lifecycle events (chat "system" messages).
42
- _LIFECYCLE_PREFIXES = ("run.", "task.", "phase.", "sandbox.", "gh.")
42
+ _LIFECYCLE_PREFIXES = ("run.", "task.", "phase.", "sandbox.", "gh.", "policy.")
43
43
 
44
44
  # High-volume noise excluded from the transcript (still queryable via
45
45
  # `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats,
@@ -153,7 +153,12 @@ def render_event(event: Event) -> RenderableType | None:
153
153
  style = "dim"
154
154
  if event.type == HostEventTypes.TASK_STATE:
155
155
  style = TASK_STATE_STYLES.get(str(data.get("state", "")), "dim")
156
- elif "fallback" in event.type or "missing" in event.type or "warning" in event.type:
156
+ elif (
157
+ "fallback" in event.type
158
+ or "missing" in event.type
159
+ or "warning" in event.type
160
+ or event.type == HostEventTypes.POLICY_DENY
161
+ ):
157
162
  style = "yellow"
158
163
  return Text(line, style=style, overflow="fold")
159
164
 
@@ -49,6 +49,35 @@ class SandboxConfig(_ConfigModel):
49
49
  extra_allow_domains: list[str] = Field(default_factory=list)
50
50
 
51
51
 
52
+ class PolicyConfig(_ConfigModel):
53
+ """Operator bounds for plan-declared egress.
54
+
55
+ The PLAN phase may request extra network allows for EXECUTE (each with a
56
+ justification). Requests are auto-granted just before EXECUTE only when
57
+ they match ``allow`` and no ``deny`` pattern; everything is event-logged
58
+ (``sbxloop logs RUN --type policy.``). Patterns are exact domains,
59
+ ``*.example.com`` wildcards (the domain and all subdomains), or ``*``
60
+ (everything). Empty ``allow`` — the default — means plans may only use
61
+ the always-reachable baseline (Copilot/GitHub hosts, PyPI, apt mirrors).
62
+ """
63
+
64
+ allow: list[str] = Field(default_factory=list)
65
+ deny: list[str] = Field(default_factory=list)
66
+
67
+ @field_validator("allow", "deny")
68
+ @classmethod
69
+ def _check_patterns(cls, value: list[str]) -> list[str]:
70
+ from sbxloop.policy import valid_pattern
71
+
72
+ value = [p.strip().lower() for p in value]
73
+ bad = [p for p in value if not valid_pattern(p, operator=True)]
74
+ if bad:
75
+ raise ValueError(
76
+ f"invalid egress pattern(s) {bad}: use a domain, *.domain wildcard, or *"
77
+ )
78
+ return value
79
+
80
+
52
81
  class GithubConfig(_ConfigModel):
53
82
  """The GitHub integration. ``repo`` is the gate: unset (the default)
54
83
  disables GitHub entirely — no github sandbox is provisioned, no GH_TOKEN
@@ -134,6 +163,7 @@ class Config(_ConfigModel):
134
163
  worker_python: str = "/home/agent/.sbxloop/venv/bin/python"
135
164
  install_workers: bool = True
136
165
  sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
166
+ policy: PolicyConfig = Field(default_factory=PolicyConfig)
137
167
  github: GithubConfig = Field(default_factory=GithubConfig)
138
168
  budgets: Budgets = Field(default_factory=Budgets)
139
169
  limits: Limits = Field(default_factory=Limits)
@@ -41,6 +41,7 @@ from sbxloop.events import EventBus, Hook, HostEventTypes
41
41
  from sbxloop.gh.ops import GithubOps
42
42
  from sbxloop.gh.reporter import GithubReporterHook
43
43
  from sbxloop.ids import new_run_id
44
+ from sbxloop.policy import EgressGranter
44
45
  from sbxloop.sbx.cli import SbxCLI
45
46
  from sbxloop.sbx.pair import SandboxPair
46
47
  from sbxloop.sbx.provision import Provisioner
@@ -163,7 +164,10 @@ class LoopEngine:
163
164
  phases = PhaseRunner(
164
165
  agent, self.config, run_id, outcome, workdir=pair.agent_workdir
165
166
  )
166
- state = self._run_phases(run_id, phases, deadline, pair)
167
+ granter = EgressGranter(
168
+ self.sbx, self.config, self.bus, run_id, pair.agent.name
169
+ )
170
+ state = self._run_phases(run_id, phases, deadline, pair, granter)
167
171
  # Summary must post while the github sandbox is alive;
168
172
  # on an infra exception the run is resumable and the
169
173
  # resumed run reopens the same tracking issue.
@@ -334,7 +338,12 @@ class LoopEngine:
334
338
  self.bus.emit(HostEventTypes.RUN_DELIVER, run_id, repo=repo, pr=pr.number, url=pr.url)
335
339
 
336
340
  def _run_phases(
337
- self, run_id: str, phases: PhaseRunner, deadline: float, pair: SandboxPair
341
+ self,
342
+ run_id: str,
343
+ phases: PhaseRunner,
344
+ deadline: float,
345
+ pair: SandboxPair,
346
+ granter: EgressGranter,
338
347
  ) -> RunState:
339
348
  tasks = self.store.get_tasks(run_id)
340
349
  if not tasks:
@@ -373,7 +382,7 @@ class LoopEngine:
373
382
  self.store.update_task(run_id, task)
374
383
  self._emit_task_end(run_id, task)
375
384
  continue
376
- self._run_task(run_id, phases, task, deadline, pair)
385
+ self._run_task(run_id, phases, task, deadline, pair, granter)
377
386
  if task.state == "failed":
378
387
  failed_ids.add(task.spec.id)
379
388
 
@@ -389,6 +398,7 @@ class LoopEngine:
389
398
  task: TaskRecord,
390
399
  deadline: float,
391
400
  pair: SandboxPair,
401
+ granter: EgressGranter,
392
402
  ) -> None:
393
403
  budgets = self.config.budgets
394
404
  if task.state == "pending":
@@ -418,7 +428,7 @@ class LoopEngine:
418
428
  if task.state == "planning":
419
429
  self._phase_plan(run_id, phases, task)
420
430
  elif task.state == "executing":
421
- self._phase_execute_and_scrutinize(run_id, phases, task, budgets)
431
+ self._phase_execute_and_scrutinize(run_id, phases, task, granter)
422
432
  elif task.state == "verifying":
423
433
  self._phase_verify(run_id, phases, task, budgets)
424
434
  elif task.state == "validating":
@@ -451,9 +461,14 @@ class LoopEngine:
451
461
  run_id: str,
452
462
  phases: PhaseRunner,
453
463
  task: TaskRecord,
454
- budgets: object,
464
+ granter: EgressGranter,
455
465
  ) -> None:
456
466
  assert task.plan is not None
467
+ # Grant-late: plan-declared egress is applied at EXECUTE entry, not
468
+ # at plan time. Runs here (not in _phase_plan) so resumed tasks whose
469
+ # persisted state skips planning still get their grants on the
470
+ # freshly provisioned sandbox.
471
+ granter.apply(task.spec.id, [(egress.domain, egress.reason) for egress in task.plan.egress])
457
472
  started = time.time()
458
473
  result = phases.execute(task, task.plan)
459
474
  task.session_id = result.session_id
@@ -6,7 +6,7 @@ from graphlib import CycleError, TopologicalSorter
6
6
  from pathlib import Path
7
7
  from typing import Literal
8
8
 
9
- from pydantic import BaseModel, ConfigDict, Field, model_validator
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
10
10
 
11
11
  RunState = Literal[
12
12
  "created",
@@ -95,12 +95,40 @@ class TaskGraph(_Model):
95
95
  return 1 + max(self._depth(by_id[d], by_id) for d in task.depends_on)
96
96
 
97
97
 
98
+ class EgressSpec(_Model):
99
+ """One plan-declared network need: a domain EXECUTE must reach, and why.
100
+
101
+ Granted to the agent sandbox just before EXECUTE — but only within the
102
+ operator's ``[policy]`` bounds (checked at plan time, enforced again at
103
+ grant time). ``domain`` is a bare domain or ``*.domain`` wildcard; no
104
+ scheme, path, or port, and never the bare ``"*"``.
105
+ """
106
+
107
+ domain: str
108
+ reason: str = ""
109
+
110
+ @field_validator("domain")
111
+ @classmethod
112
+ def _check_domain(cls, value: str) -> str:
113
+ from sbxloop.policy import valid_pattern
114
+
115
+ value = value.strip().lower()
116
+ if not valid_pattern(value):
117
+ raise ValueError(
118
+ f"egress domain must be a domain or *.domain wildcard "
119
+ f"(no scheme/path/port), got {value!r}"
120
+ )
121
+ return value
122
+
123
+
98
124
  class PlanModel(_Model):
99
125
  """The agent's plan for one task."""
100
126
 
101
127
  steps: list[str]
102
128
  expected_artifacts: list[str] = Field(default_factory=list)
103
129
  verify_commands: list[str] = Field(default_factory=list)
130
+ # External domains EXECUTE needs beyond the baseline, with justification.
131
+ egress: list[EgressSpec] = Field(default_factory=list)
104
132
 
105
133
 
106
134
  class Issue(_Model):
@@ -15,9 +15,10 @@ Session strategy per phase (a deliberate design decision):
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ from collections.abc import Callable
18
19
  from typing import Literal, TypeVar
19
20
 
20
- from pydantic import BaseModel, ValidationError
21
+ from pydantic import BaseModel
21
22
 
22
23
  from sbxloop.config import Config
23
24
  from sbxloop.engine.model import PlanModel, TaskGraph, TaskRecord, Verdict
@@ -99,16 +100,25 @@ class PhaseRunner:
99
100
  context: dict[str, str],
100
101
  *,
101
102
  permission_mode: Literal["auto", "read_only"] = "auto",
103
+ check: Callable[[ModelT], None] | None = None,
102
104
  ) -> ModelT:
103
- """Run a JSON-expecting agent job; one retry with the validation error."""
105
+ """Run a JSON-expecting agent job; one retry with the validation error.
106
+
107
+ ``check`` runs host-side semantic validation on the parsed model and
108
+ raises ValueError to reject — same retry path as schema failures
109
+ (pydantic's ValidationError is a ValueError subclass).
110
+ """
104
111
  retry_context = ""
105
112
  last_error: Exception | None = None
106
113
  for _ in range(2):
107
114
  prompt = render(prompt_name, retry_context=retry_context, **context)
108
115
  result = self._agent_job(prompt, permission_mode=permission_mode, expect="json")
109
116
  try:
110
- return model_cls.model_validate(result.output_json)
111
- except ValidationError as exc:
117
+ model = model_cls.model_validate(result.output_json)
118
+ if check is not None:
119
+ check(model)
120
+ return model
121
+ except ValueError as exc: # includes pydantic's ValidationError
112
122
  last_error = exc
113
123
  retry_context = (
114
124
  "\n## Previous attempt was invalid\n\n"
@@ -156,8 +166,33 @@ class PhaseRunner:
156
166
  "acceptance_criteria": bullet_list(task.spec.acceptance_criteria),
157
167
  "feedback": task.last_feedback or "(none — first attempt)",
158
168
  },
169
+ check=self._check_plan_egress,
159
170
  )
160
171
 
172
+ def _check_plan_egress(self, plan: PlanModel) -> None:
173
+ """Reject plans declaring egress outside the operator's bounds.
174
+
175
+ Feeds the retry loop, so the planner gets one chance to drop the
176
+ domain (or find a baseline-reachable alternative) before the run
177
+ fails — the "grant only within operator-set limits" guardrail.
178
+ """
179
+ from sbxloop.policy import effective_egress_bounds, egress_rejection
180
+
181
+ allow, deny = effective_egress_bounds(self.config)
182
+ problems = [
183
+ f"- {egress.domain}: {rejection}"
184
+ for egress in plan.egress
185
+ if (rejection := egress_rejection(egress.domain, allow, deny)) is not None
186
+ ]
187
+ if problems:
188
+ raise ValueError(
189
+ "plan-declared egress is outside the operator's bounds:\n"
190
+ + "\n".join(problems)
191
+ + "\nDrop these domains from `egress` (prefer baseline-reachable hosts: "
192
+ "PyPI, GitHub, apt mirrors). Only the operator can extend the bounds, "
193
+ "via [policy] allow in sbxloop.toml."
194
+ )
195
+
161
196
  def execute(self, task: TaskRecord, plan: PlanModel) -> JobResult:
162
197
  prompt = render(
163
198
  "execute",
@@ -17,11 +17,13 @@ destroyed.
17
17
  project commands through `.venv/bin/...`.
18
18
  - If a tool or apt package is missing, you have passwordless sudo:
19
19
  `sudo apt-get install -y <package>`.
20
- - Network egress is allowlisted. PyPI, GitHub, and apt mirrors are
21
- reachable; other hosts (npm registry, arbitrary APIs) may not be. If a
22
- download times out repeatedly, say so in your summary instead of
23
- retrying forever the allowlist is user configuration, not something
24
- you can change.
20
+ - Network egress is allowlisted. PyPI, GitHub, apt mirrors, and any
21
+ domains the plan declared as `egress` are reachable; other hosts (npm
22
+ registry, arbitrary APIs) may not be. If a download times out
23
+ repeatedly, treat the host as blocked: name the exact blocked domain in
24
+ your summary — a re-plan can declare it — instead of retrying forever.
25
+ The allowlist is operator-bounded configuration, not something you can
26
+ change from in here.
25
27
 
26
28
  ## Overall outcome
27
29
 
@@ -26,17 +26,27 @@ Respond with exactly one fenced JSON block:
26
26
  {
27
27
  "steps": ["specific action 1", "specific action 2"],
28
28
  "expected_artifacts": ["files or outputs this task should produce"],
29
- "verify_commands": ["shell commands that exit 0 only when the work is correct"]
29
+ "verify_commands": ["shell commands that exit 0 only when the work is correct"],
30
+ "egress": [{"domain": "registry.npmjs.org", "reason": "npm install for the build"}]
30
31
  }
31
32
  ```
32
33
 
33
34
  Steps must be specific enough that an executor with no other context can
34
35
  follow them. Include the task's own verification ideas in `verify_commands`.
35
36
 
37
+ `egress` declares external domains the executor will need to reach beyond
38
+ the baseline. PyPI, GitHub, and apt mirrors are always reachable — never
39
+ declare those. Each entry needs a short justification; use `[]` when the
40
+ baseline suffices (the common case). Domains only — no scheme, path, or
41
+ port; `*.example.com` wildcards are accepted. Declarations are auto-granted
42
+ only within an operator-set allowlist: a request outside it fails this
43
+ plan's validation, so prefer baseline-reachable alternatives.
44
+
36
45
  Environment facts to plan around: Debian/Ubuntu VM; the system Python is
37
46
  externally managed (PEP 668), so Python dependencies belong in a project
38
47
  virtualenv (`python3 -m venv .venv`) and commands — including your
39
48
  `verify_commands` — should use `.venv/bin/...` paths; missing apt packages
40
49
  can be installed with passwordless sudo; network egress is allowlisted
41
- (PyPI, GitHub, and apt mirrors are reachable — other registries may not be).
50
+ (PyPI, GitHub, and apt mirrors are reachable — declare anything else in
51
+ `egress`).
42
52
  $retry_context
@@ -82,6 +82,8 @@ class HostEventTypes:
82
82
  TASK_END = "task.end"
83
83
  PHASE_START = "phase.start"
84
84
  PHASE_END = "phase.end"
85
+ POLICY_ALLOW = "policy.allow"
86
+ POLICY_DENY = "policy.deny"
85
87
  SANDBOX_PROVISION_START = "sandbox.provision_start"
86
88
  SANDBOX_READY = "sandbox.ready"
87
89
  SANDBOX_CLEANUP = "sandbox.cleanup"
@@ -0,0 +1,167 @@
1
+ """Plan-declared network egress: bounds checking and grant-late application.
2
+
3
+ The PLAN phase may declare external domains a task needs during EXECUTE
4
+ (``PlanModel.egress``). Declarations are validated against operator-set
5
+ bounds (``[policy] allow`` / ``[policy] deny`` in sbxloop.toml) at plan time,
6
+ and granted to the agent sandbox — via ``sbx policy allow network <domain>
7
+ --sandbox <agent>`` — only just before EXECUTE runs ("grant late"). Every
8
+ grant and refusal is emitted as a ``policy.*`` run event, so the persisted
9
+ event log doubles as an egress audit trail (``sbxloop logs RUN --type
10
+ policy.``).
11
+
12
+ sbx 0.35 has no primitive for revoking or narrowing an allow on a live
13
+ sandbox, so grants are additive for the sandbox's lifetime — SCRUTINIZE and
14
+ VERIFY inherit whatever EXECUTE was granted. Grants never outlive a run:
15
+ sandboxes are removed at run end, and resume provisions fresh ones. If a
16
+ future sbx grows revocation, this module is where "revoke early" lands.
17
+
18
+ Pattern semantics (ours, not sbx's — bounds govern what may be *requested*;
19
+ the requested domain is passed to sbx verbatim): a pattern is an exact
20
+ domain, a wildcard ``*.example.com`` (covers ``example.com`` and every
21
+ subdomain), or the operator-only ``*`` (covers everything).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ from sbxloop.config import Config
29
+ from sbxloop.errors import SbxError
30
+ from sbxloop.events import EventBus, HostEventTypes
31
+ from sbxloop.sbx.cli import SbxCLI
32
+
33
+ DOMAIN_PATTERN_RE = re.compile(r"(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}")
34
+
35
+ # Hosts the plan/execute prompts advertise as reachable (the user's balanced
36
+ # preset covers PyPI and apt mirrors). A plan declaring one of these is
37
+ # in-bounds without operator configuration — granting it per-sandbox cannot
38
+ # expand egress beyond what the prompts already promise, and makes the
39
+ # promise hold even under a preset that lacks the host.
40
+ PROMPT_ADVERTISED_DOMAINS = (
41
+ "pypi.org",
42
+ "files.pythonhosted.org",
43
+ "deb.debian.org",
44
+ "security.debian.org",
45
+ "archive.ubuntu.com",
46
+ "ports.ubuntu.com",
47
+ )
48
+
49
+
50
+ def valid_pattern(pattern: str, *, operator: bool = False) -> bool:
51
+ """Whether ``pattern`` is a well-formed domain pattern.
52
+
53
+ ``operator=True`` additionally permits the bare ``"*"`` — meaningful as
54
+ an operator bound ("grant anything the plan asks for") but rejected in
55
+ plan declarations, which must name what they need.
56
+ """
57
+ if pattern == "*":
58
+ return operator
59
+ return DOMAIN_PATTERN_RE.fullmatch(pattern) is not None
60
+
61
+
62
+ def pattern_covers(pattern: str, domain: str) -> bool:
63
+ """Whether a bounds pattern covers a requested domain (or wildcard)."""
64
+ pattern = pattern.lower()
65
+ domain = domain.lower()
66
+ if pattern == "*" or pattern == domain:
67
+ return True
68
+ if not pattern.startswith("*."):
69
+ return False
70
+ base = pattern[2:]
71
+ requested = domain[2:] if domain.startswith("*.") else domain
72
+ return requested == base or requested.endswith(f".{base}")
73
+
74
+
75
+ def egress_rejection(domain: str, allow: list[str], deny: list[str]) -> str | None:
76
+ """Why ``domain`` may not be granted, or None when it is in bounds."""
77
+ for pattern in deny:
78
+ if pattern_covers(pattern, domain):
79
+ return f"matches [policy] deny pattern {pattern!r}"
80
+ if not any(pattern_covers(pattern, domain) for pattern in allow):
81
+ return "not covered by [policy] allow in sbxloop.toml"
82
+ return None
83
+
84
+
85
+ def effective_egress_bounds(config: Config) -> tuple[list[str], list[str]]:
86
+ """The (allow, deny) bounds plan-declared egress is checked against.
87
+
88
+ The allow side is the operator's ``[policy] allow`` plus everything the
89
+ agent sandbox can already reach (its provision-time baseline and the
90
+ prompt-advertised hosts) — declaring an already-reachable domain must
91
+ never fail a plan. ``[policy] deny`` still wins over all of it.
92
+ """
93
+ from sbxloop.sbx.provision import AGENT_ALLOW_DOMAINS
94
+
95
+ allow = [
96
+ *AGENT_ALLOW_DOMAINS,
97
+ *config.sandbox.extra_allow_domains,
98
+ *PROMPT_ADVERTISED_DOMAINS,
99
+ *config.policy.allow,
100
+ ]
101
+ return allow, list(config.policy.deny)
102
+
103
+
104
+ class EgressGranter:
105
+ """Applies one run's plan-declared egress grants to the agent sandbox.
106
+
107
+ Grants are applied at EXECUTE entry, not at plan time — the tightest
108
+ point sbx's grant-only policy model permits. Idempotent per domain: the
109
+ provision-time baseline is pre-seeded, and revision loops re-entering
110
+ EXECUTE do not re-grant. Out-of-bounds declarations are refused with a
111
+ ``policy.deny`` event; plan validation normally rejects them, so a deny
112
+ here means the operator tightened bounds after the plan was persisted
113
+ (e.g. across a resume).
114
+ """
115
+
116
+ def __init__(self, cli: SbxCLI, config: Config, bus: EventBus, run_id: str, sandbox: str):
117
+ from sbxloop.sbx.provision import AGENT_ALLOW_DOMAINS
118
+
119
+ self.cli = cli
120
+ self.bus = bus
121
+ self.run_id = run_id
122
+ self.sandbox = sandbox
123
+ self.allow, self.deny = effective_egress_bounds(config)
124
+ self._granted = {
125
+ d.lower() for d in (*AGENT_ALLOW_DOMAINS, *config.sandbox.extra_allow_domains)
126
+ }
127
+
128
+ def apply(self, task_id: str, egress: list[tuple[str, str]]) -> None:
129
+ """Grant each declared (domain, justification), event-logging both
130
+ grants and refusals. sbx failures propagate (infrastructure error)."""
131
+ for domain, reason in egress:
132
+ domain = domain.lower()
133
+ rejection = egress_rejection(domain, self.allow, self.deny)
134
+ if rejection is not None:
135
+ self.bus.emit(
136
+ HostEventTypes.POLICY_DENY,
137
+ self.run_id,
138
+ task_id=task_id,
139
+ domain=domain,
140
+ sandbox=self.sandbox,
141
+ message=f"egress refused: {domain} — {rejection}",
142
+ )
143
+ continue
144
+ if domain in self._granted:
145
+ continue
146
+ try:
147
+ self.cli.policy_allow(domain, sandbox=self.sandbox)
148
+ except SbxError:
149
+ self.bus.emit(
150
+ HostEventTypes.POLICY_DENY,
151
+ self.run_id,
152
+ task_id=task_id,
153
+ domain=domain,
154
+ sandbox=self.sandbox,
155
+ message=f"egress grant failed: {domain} — sbx policy allow errored",
156
+ )
157
+ raise
158
+ self._granted.add(domain)
159
+ self.bus.emit(
160
+ HostEventTypes.POLICY_ALLOW,
161
+ self.run_id,
162
+ task_id=task_id,
163
+ domain=domain,
164
+ reason=reason,
165
+ sandbox=self.sandbox,
166
+ message=f"egress granted: {domain} — {reason or 'no justification given'}",
167
+ )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes