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.
- {sbxloop-0.5.7 → sbxloop-0.5.9}/PKG-INFO +1 -1
- 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
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/app.py +63 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/tui.py +7 -2
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/config.py +30 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/engine.py +20 -5
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/model.py +29 -1
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/phases.py +39 -4
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/execute.md +7 -5
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/plan.md +12 -2
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/events.py +2 -0
- sbxloop-0.5.9/src/sbxloop/policy.py +167 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/.gitignore +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/README.md +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/hatch_build.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/pyproject.toml +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/cli/doctor.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/deliver.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/errors.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/pair.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/provision.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.5.7 → sbxloop-0.5.9}/src/sbxloop/worker/client.py +0 -0
- {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.
|
|
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
|
|
Binary file
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 5,
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 —
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|