sbxloop 0.5.6__tar.gz → 0.5.8__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.6 → sbxloop-0.5.8}/PKG-INFO +1 -1
- sbxloop-0.5.8/src/sbxloop/_vendor/sbxloop_worker-0.5.8-py3-none-any.whl +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/_version.py +2 -2
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/app.py +72 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/doctor.py +23 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/tui.py +48 -5
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/config.py +68 -1
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/engine.py +73 -5
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/model.py +29 -1
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/phases.py +39 -4
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/execute.md +7 -5
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/plan.md +12 -2
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/events.py +2 -0
- sbxloop-0.5.8/src/sbxloop/policy.py +167 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/worker/client.py +22 -1
- sbxloop-0.5.6/src/sbxloop/_vendor/sbxloop_worker-0.5.6-py3-none-any.whl +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/.gitignore +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/README.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/hatch_build.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/pyproject.toml +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/cli/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/deliver.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/decompose.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/scrutinize.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/prompts/validate.md +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/engine/store.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/errors.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/gh/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/gh/ops.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/gh/reporter.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/ids.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/py.typed +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/cli.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/models.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/pair.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/parse.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/provision.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/prune.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/sbx/sandbox.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/src/sbxloop/worker/__init__.py +0 -0
- {sbxloop-0.5.6 → sbxloop-0.5.8}/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.8
|
|
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.8'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 8)
|
|
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
|
|
@@ -655,6 +718,15 @@ max_replans_per_task = 1
|
|
|
655
718
|
max_tasks = 20
|
|
656
719
|
max_wall_clock_s = 7200.0
|
|
657
720
|
per_job_timeout_s = 900.0
|
|
721
|
+
|
|
722
|
+
[limits]
|
|
723
|
+
# Sandbox resource guardrails (percent used; 0 disables). Sampled in-VM on
|
|
724
|
+
# the worker heartbeat and shown as a gauge in the TUI status panel.
|
|
725
|
+
# Crossing disk_abort fails the current task with an explicit
|
|
726
|
+
# "sandbox disk exhausted" error.
|
|
727
|
+
disk_warn = 85.0
|
|
728
|
+
disk_abort = 95.0
|
|
729
|
+
mem_warn = 90.0
|
|
658
730
|
"""
|
|
659
731
|
|
|
660
732
|
|
|
@@ -122,6 +122,29 @@ def collect_checks(
|
|
|
122
122
|
)
|
|
123
123
|
)
|
|
124
124
|
|
|
125
|
+
# Host-side resource stats: sbx 0.35.x has no stats command, so
|
|
126
|
+
# resource telemetry samples in-VM on the worker heartbeat. This is
|
|
127
|
+
# the field-check for the day sbx grows one (issue #54) — purely
|
|
128
|
+
# informational either way.
|
|
129
|
+
report("checking for host-side sandbox stats")
|
|
130
|
+
stats_available = False
|
|
131
|
+
try:
|
|
132
|
+
stats_available = cli.run("stats", "--help", check=False).returncode == 0
|
|
133
|
+
except SbxError:
|
|
134
|
+
stats_available = False
|
|
135
|
+
checks.append(
|
|
136
|
+
Check(
|
|
137
|
+
"sandbox stats",
|
|
138
|
+
True,
|
|
139
|
+
"host-side `sbx stats` available — sbxloop still samples in-VM; "
|
|
140
|
+
"consider filing an issue to prefer host-side stats"
|
|
141
|
+
if stats_available
|
|
142
|
+
else "no host-side `sbx stats`; resource telemetry samples in-VM "
|
|
143
|
+
f"on the worker heartbeat (expected on {TESTED_SBX_SERIES}.x)",
|
|
144
|
+
hard=False,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
125
148
|
# tokens
|
|
126
149
|
checks.append(
|
|
127
150
|
Check(
|
|
@@ -39,10 +39,12 @@ 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
|
-
# `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats
|
|
45
|
+
# `sbxloop logs`): streaming deltas, raw stdout passthrough, heartbeats,
|
|
46
|
+
# per-beat resource samples (they live in the pinned gauge instead —
|
|
47
|
+
# threshold *crossings* arrive as sandbox.resources_warning and do print).
|
|
46
48
|
_TRANSCRIPT_SKIP = {
|
|
47
49
|
EventTypes.AGENT_MESSAGE_DELTA,
|
|
48
50
|
EventTypes.WORKER_STDOUT,
|
|
@@ -51,8 +53,11 @@ _TRANSCRIPT_SKIP = {
|
|
|
51
53
|
EventTypes.WORKER_RESULT,
|
|
52
54
|
EventTypes.WORKER_END,
|
|
53
55
|
EventTypes.AGENT_USAGE,
|
|
56
|
+
EventTypes.SANDBOX_RESOURCES,
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
RESOURCE_LEVEL_STYLES = {"ok": "dim", "warn": "yellow", "abort": "bold red"}
|
|
60
|
+
|
|
56
61
|
AGENT_MESSAGE_CLIP = 4000
|
|
57
62
|
# One-line clip for tool arguments; output tail lines shown on tool failure.
|
|
58
63
|
TOOL_ARGS_LINE_CLIP = 160
|
|
@@ -148,7 +153,12 @@ def render_event(event: Event) -> RenderableType | None:
|
|
|
148
153
|
style = "dim"
|
|
149
154
|
if event.type == HostEventTypes.TASK_STATE:
|
|
150
155
|
style = TASK_STATE_STYLES.get(str(data.get("state", "")), "dim")
|
|
151
|
-
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
|
+
):
|
|
152
162
|
style = "yellow"
|
|
153
163
|
return Text(line, style=style, overflow="fold")
|
|
154
164
|
|
|
@@ -166,13 +176,18 @@ class Dashboard:
|
|
|
166
176
|
self.run_state: str = "starting"
|
|
167
177
|
self.outcome: str = ""
|
|
168
178
|
self.tasks: dict[str, dict[str, Any]] = {}
|
|
179
|
+
# Latest resource sample per sandbox role, rendered as one compact
|
|
180
|
+
# gauge line each; only escalates visually past warn/abort levels.
|
|
181
|
+
self.resources: dict[str, dict[str, Any]] = {}
|
|
169
182
|
|
|
170
183
|
# -- event intake ------------------------------------------------------
|
|
171
184
|
|
|
172
185
|
def on_event(self, event: Event) -> None:
|
|
173
186
|
self.run_id = self.run_id or event.run_id
|
|
174
187
|
data = event.data
|
|
175
|
-
if event.type ==
|
|
188
|
+
if event.type == EventTypes.SANDBOX_RESOURCES:
|
|
189
|
+
self.resources[str(data.get("role") or "sandbox")] = dict(data)
|
|
190
|
+
elif event.type == HostEventTypes.RUN_START:
|
|
176
191
|
self.outcome = str(data.get("outcome", ""))
|
|
177
192
|
elif event.type in (HostEventTypes.RUN_STATE, HostEventTypes.RUN_END):
|
|
178
193
|
self.run_state = str(data.get("state", self.run_state))
|
|
@@ -219,12 +234,29 @@ class Dashboard:
|
|
|
219
234
|
if not self.tasks:
|
|
220
235
|
table.add_row("…", "decomposing outcome", Text("pending", style="dim"), "")
|
|
221
236
|
|
|
237
|
+
gauges = [self._gauge_line(role) for role in sorted(self.resources)]
|
|
222
238
|
return Panel(
|
|
223
|
-
Group(header, outcome, table),
|
|
239
|
+
Group(header, outcome, table, *gauges),
|
|
224
240
|
border_style="cyan",
|
|
225
241
|
padding=(0, 1),
|
|
226
242
|
)
|
|
227
243
|
|
|
244
|
+
def _gauge_line(self, role: str) -> Text:
|
|
245
|
+
sample = self.resources[role]
|
|
246
|
+
level = str(sample.get("level", "ok"))
|
|
247
|
+
parts = []
|
|
248
|
+
if sample.get("disk_used_pct") is not None:
|
|
249
|
+
parts.append(f"disk {sample['disk_used_pct']:.0f}%")
|
|
250
|
+
if sample.get("mem_used_pct") is not None:
|
|
251
|
+
parts.append(f"mem {sample['mem_used_pct']:.0f}%")
|
|
252
|
+
if sample.get("load1") is not None:
|
|
253
|
+
parts.append(f"load {sample['load1']}")
|
|
254
|
+
suffix = " ⚠ " + level if level != "ok" else ""
|
|
255
|
+
return Text(
|
|
256
|
+
f"{role}: " + " · ".join(parts) + suffix,
|
|
257
|
+
style=RESOURCE_LEVEL_STYLES.get(level, "dim"),
|
|
258
|
+
)
|
|
259
|
+
|
|
228
260
|
|
|
229
261
|
def format_event(event: Event) -> str:
|
|
230
262
|
"""Dense single-line form (used by `sbxloop logs` and lifecycle lines)."""
|
|
@@ -239,6 +271,17 @@ def format_event(event: Event) -> str:
|
|
|
239
271
|
value = str(event.data[key]).replace("\n", " ")
|
|
240
272
|
parts.append(value[:160])
|
|
241
273
|
break
|
|
274
|
+
if event.data.get("disk_used_pct") is not None:
|
|
275
|
+
# Resource samples: make `sbxloop logs --type-prefix sandbox.resources`
|
|
276
|
+
# answer "what did disk look like before the failure" at a glance.
|
|
277
|
+
summary = [f"disk={event.data['disk_used_pct']}%"]
|
|
278
|
+
if event.data.get("mem_used_pct") is not None:
|
|
279
|
+
summary.append(f"mem={event.data['mem_used_pct']}%")
|
|
280
|
+
if event.data.get("load1") is not None:
|
|
281
|
+
summary.append(f"load={event.data['load1']}")
|
|
282
|
+
if event.data.get("level"):
|
|
283
|
+
summary.append(str(event.data["level"]))
|
|
284
|
+
parts.append(" ".join(summary))
|
|
242
285
|
if event.data.get("args"):
|
|
243
286
|
parts.append(_one_line(str(event.data["args"]), 120))
|
|
244
287
|
if picked != "error" and event.data.get("error"):
|
|
@@ -18,7 +18,14 @@ from collections.abc import Mapping
|
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
from typing import Any, Literal
|
|
20
20
|
|
|
21
|
-
from pydantic import
|
|
21
|
+
from pydantic import (
|
|
22
|
+
BaseModel,
|
|
23
|
+
ConfigDict,
|
|
24
|
+
Field,
|
|
25
|
+
ValidationError,
|
|
26
|
+
field_validator,
|
|
27
|
+
model_validator,
|
|
28
|
+
)
|
|
22
29
|
|
|
23
30
|
from sbxloop.errors import ConfigError
|
|
24
31
|
|
|
@@ -42,6 +49,35 @@ class SandboxConfig(_ConfigModel):
|
|
|
42
49
|
extra_allow_domains: list[str] = Field(default_factory=list)
|
|
43
50
|
|
|
44
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
|
+
|
|
45
81
|
class GithubConfig(_ConfigModel):
|
|
46
82
|
"""The GitHub integration. ``repo`` is the gate: unset (the default)
|
|
47
83
|
disables GitHub entirely — no github sandbox is provisioned, no GH_TOKEN
|
|
@@ -68,6 +104,35 @@ class GithubConfig(_ConfigModel):
|
|
|
68
104
|
return self.repo is not None
|
|
69
105
|
|
|
70
106
|
|
|
107
|
+
class Limits(_ConfigModel):
|
|
108
|
+
"""Sandbox resource guardrails, sampled in-VM on the worker heartbeat.
|
|
109
|
+
|
|
110
|
+
Thresholds are percent-used of the workspace filesystem / memory; 0
|
|
111
|
+
disables a guardrail. Crossing a warn threshold emits a prominent
|
|
112
|
+
``sandbox.resources_warning`` event and escalates the TUI gauge;
|
|
113
|
+
crossing ``disk_abort`` fails the current task with an explicit
|
|
114
|
+
"sandbox disk exhausted" error instead of letting in-VM tooling fail
|
|
115
|
+
confusingly on a full disk.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
disk_warn: float = 85.0
|
|
119
|
+
disk_abort: float = 95.0
|
|
120
|
+
mem_warn: float = 90.0
|
|
121
|
+
|
|
122
|
+
@model_validator(mode="after")
|
|
123
|
+
def _check_thresholds(self) -> Limits:
|
|
124
|
+
for name in ("disk_warn", "disk_abort", "mem_warn"):
|
|
125
|
+
value = getattr(self, name)
|
|
126
|
+
if value < 0 or value > 100:
|
|
127
|
+
raise ValueError(f"limits.{name} must be a percentage in 0..100, got {value}")
|
|
128
|
+
if 0 < self.disk_abort <= self.disk_warn:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"limits.disk_abort ({self.disk_abort}) must be greater than "
|
|
131
|
+
f"limits.disk_warn ({self.disk_warn})"
|
|
132
|
+
)
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
|
|
71
136
|
class Budgets(_ConfigModel):
|
|
72
137
|
max_revisions_per_task: int = 2
|
|
73
138
|
max_replans_per_task: int = 1
|
|
@@ -98,8 +163,10 @@ class Config(_ConfigModel):
|
|
|
98
163
|
worker_python: str = "/home/agent/.sbxloop/venv/bin/python"
|
|
99
164
|
install_workers: bool = True
|
|
100
165
|
sandbox: SandboxConfig = Field(default_factory=SandboxConfig)
|
|
166
|
+
policy: PolicyConfig = Field(default_factory=PolicyConfig)
|
|
101
167
|
github: GithubConfig = Field(default_factory=GithubConfig)
|
|
102
168
|
budgets: Budgets = Field(default_factory=Budgets)
|
|
169
|
+
limits: Limits = Field(default_factory=Limits)
|
|
103
170
|
|
|
104
171
|
|
|
105
172
|
def _read_toml(path: Path) -> dict[str, Any]:
|
|
@@ -18,6 +18,7 @@ import json
|
|
|
18
18
|
import logging
|
|
19
19
|
import time
|
|
20
20
|
from collections.abc import Callable, Sequence
|
|
21
|
+
from typing import Any
|
|
21
22
|
|
|
22
23
|
from sbxloop.config import Config, load_config, load_dotenv_file
|
|
23
24
|
from sbxloop.deliver import deliver_workspace
|
|
@@ -40,6 +41,7 @@ from sbxloop.events import EventBus, Hook, HostEventTypes
|
|
|
40
41
|
from sbxloop.gh.ops import GithubOps
|
|
41
42
|
from sbxloop.gh.reporter import GithubReporterHook
|
|
42
43
|
from sbxloop.ids import new_run_id
|
|
44
|
+
from sbxloop.policy import EgressGranter
|
|
43
45
|
from sbxloop.sbx.cli import SbxCLI
|
|
44
46
|
from sbxloop.sbx.pair import SandboxPair
|
|
45
47
|
from sbxloop.sbx.provision import Provisioner
|
|
@@ -75,9 +77,13 @@ class LoopEngine:
|
|
|
75
77
|
install_workers if install_workers is not None else self.config.install_workers
|
|
76
78
|
)
|
|
77
79
|
self.clock = clock
|
|
80
|
+
# Latest sandbox.resources sample per sandbox role, fed by the bus;
|
|
81
|
+
# consulted for the disk guardrail and the harvest-truncation note.
|
|
82
|
+
self._last_resources: dict[str, dict[str, object]] = {}
|
|
78
83
|
for hook in hooks:
|
|
79
84
|
self.bus.attach_hook(hook)
|
|
80
85
|
self.bus.subscribe(self._persist_event)
|
|
86
|
+
self.bus.subscribe(self._track_resources)
|
|
81
87
|
|
|
82
88
|
def _persist_event(self, event: object) -> None:
|
|
83
89
|
from sbxloop_worker.protocol import Event
|
|
@@ -85,6 +91,14 @@ class LoopEngine:
|
|
|
85
91
|
assert isinstance(event, Event)
|
|
86
92
|
self.store.append_event(event)
|
|
87
93
|
|
|
94
|
+
def _track_resources(self, event: object) -> None:
|
|
95
|
+
from sbxloop_worker.protocol import Event, EventTypes
|
|
96
|
+
|
|
97
|
+
assert isinstance(event, Event)
|
|
98
|
+
if event.type == EventTypes.SANDBOX_RESOURCES:
|
|
99
|
+
role = str(event.data.get("role") or "agent")
|
|
100
|
+
self._last_resources[role] = dict(event.data)
|
|
101
|
+
|
|
88
102
|
# -- public API --------------------------------------------------------
|
|
89
103
|
|
|
90
104
|
def start(self, outcome: str, *, run_id: str | None = None) -> RunResult:
|
|
@@ -124,6 +138,8 @@ class LoopEngine:
|
|
|
124
138
|
self.bus,
|
|
125
139
|
transport=self.config.worker_transport,
|
|
126
140
|
python=self.worker_python,
|
|
141
|
+
role="agent",
|
|
142
|
+
limits=self.config.limits,
|
|
127
143
|
)
|
|
128
144
|
github = (
|
|
129
145
|
WorkerClient(
|
|
@@ -131,6 +147,8 @@ class LoopEngine:
|
|
|
131
147
|
self.bus,
|
|
132
148
|
transport=self.config.worker_transport,
|
|
133
149
|
python=self.worker_python,
|
|
150
|
+
role="github",
|
|
151
|
+
limits=self.config.limits,
|
|
134
152
|
)
|
|
135
153
|
if pair.github is not None
|
|
136
154
|
else None
|
|
@@ -146,7 +164,10 @@ class LoopEngine:
|
|
|
146
164
|
phases = PhaseRunner(
|
|
147
165
|
agent, self.config, run_id, outcome, workdir=pair.agent_workdir
|
|
148
166
|
)
|
|
149
|
-
|
|
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)
|
|
150
171
|
# Summary must post while the github sandbox is alive;
|
|
151
172
|
# on an infra exception the run is resumable and the
|
|
152
173
|
# resumed run reopens the same tracking issue.
|
|
@@ -249,12 +270,28 @@ class LoopEngine:
|
|
|
249
270
|
if target is None or not target.is_dir():
|
|
250
271
|
return
|
|
251
272
|
count = sum(1 for p in target.rglob("*") if p.is_file())
|
|
273
|
+
extra: dict[str, Any] = {}
|
|
274
|
+
sample = self._last_resources.get("agent")
|
|
275
|
+
if sample and sample.get("level") in ("warn", "abort"):
|
|
276
|
+
# Disk was under pressure at the last sample — harvested
|
|
277
|
+
# artifacts may be truncated or missing.
|
|
278
|
+
extra = {
|
|
279
|
+
"disk_used_pct": sample.get("disk_used_pct"),
|
|
280
|
+
"resources_level": sample.get("level"),
|
|
281
|
+
}
|
|
282
|
+
logger.warning(
|
|
283
|
+
"run %s: sandbox disk was at %s%% at the last sample — "
|
|
284
|
+
"harvested artifacts may be incomplete",
|
|
285
|
+
run_id,
|
|
286
|
+
sample.get("disk_used_pct"),
|
|
287
|
+
)
|
|
252
288
|
self.bus.emit(
|
|
253
289
|
HostEventTypes.RUN_ARTIFACTS,
|
|
254
290
|
run_id,
|
|
255
291
|
path=str(target),
|
|
256
292
|
files=count,
|
|
257
293
|
mounted=pair.mounted,
|
|
294
|
+
**extra,
|
|
258
295
|
)
|
|
259
296
|
|
|
260
297
|
def _deliver(
|
|
@@ -301,7 +338,12 @@ class LoopEngine:
|
|
|
301
338
|
self.bus.emit(HostEventTypes.RUN_DELIVER, run_id, repo=repo, pr=pr.number, url=pr.url)
|
|
302
339
|
|
|
303
340
|
def _run_phases(
|
|
304
|
-
self,
|
|
341
|
+
self,
|
|
342
|
+
run_id: str,
|
|
343
|
+
phases: PhaseRunner,
|
|
344
|
+
deadline: float,
|
|
345
|
+
pair: SandboxPair,
|
|
346
|
+
granter: EgressGranter,
|
|
305
347
|
) -> RunState:
|
|
306
348
|
tasks = self.store.get_tasks(run_id)
|
|
307
349
|
if not tasks:
|
|
@@ -340,7 +382,7 @@ class LoopEngine:
|
|
|
340
382
|
self.store.update_task(run_id, task)
|
|
341
383
|
self._emit_task_end(run_id, task)
|
|
342
384
|
continue
|
|
343
|
-
self._run_task(run_id, phases, task, deadline, pair)
|
|
385
|
+
self._run_task(run_id, phases, task, deadline, pair, granter)
|
|
344
386
|
if task.state == "failed":
|
|
345
387
|
failed_ids.add(task.spec.id)
|
|
346
388
|
|
|
@@ -356,6 +398,7 @@ class LoopEngine:
|
|
|
356
398
|
task: TaskRecord,
|
|
357
399
|
deadline: float,
|
|
358
400
|
pair: SandboxPair,
|
|
401
|
+
granter: EgressGranter,
|
|
359
402
|
) -> None:
|
|
360
403
|
budgets = self.config.budgets
|
|
361
404
|
if task.state == "pending":
|
|
@@ -374,10 +417,18 @@ class LoopEngine:
|
|
|
374
417
|
|
|
375
418
|
while not task.terminal:
|
|
376
419
|
self._check_cancelled_and_clock(run_id, deadline)
|
|
420
|
+
abort_reason = self._resource_abort_reason()
|
|
421
|
+
if abort_reason:
|
|
422
|
+
# Fail the task with a diagnosis instead of letting the next
|
|
423
|
+
# phase produce garbage in a full sandbox. Dependents are
|
|
424
|
+
# skipped by the normal failed-task machinery.
|
|
425
|
+
task.last_feedback = abort_reason
|
|
426
|
+
self._set_task_state(run_id, task, "failed")
|
|
427
|
+
break
|
|
377
428
|
if task.state == "planning":
|
|
378
429
|
self._phase_plan(run_id, phases, task)
|
|
379
430
|
elif task.state == "executing":
|
|
380
|
-
self._phase_execute_and_scrutinize(run_id, phases, task,
|
|
431
|
+
self._phase_execute_and_scrutinize(run_id, phases, task, granter)
|
|
381
432
|
elif task.state == "verifying":
|
|
382
433
|
self._phase_verify(run_id, phases, task, budgets)
|
|
383
434
|
elif task.state == "validating":
|
|
@@ -410,9 +461,14 @@ class LoopEngine:
|
|
|
410
461
|
run_id: str,
|
|
411
462
|
phases: PhaseRunner,
|
|
412
463
|
task: TaskRecord,
|
|
413
|
-
|
|
464
|
+
granter: EgressGranter,
|
|
414
465
|
) -> None:
|
|
415
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])
|
|
416
472
|
started = time.time()
|
|
417
473
|
result = phases.execute(task, task.plan)
|
|
418
474
|
task.session_id = result.session_id
|
|
@@ -510,6 +566,18 @@ class LoopEngine:
|
|
|
510
566
|
|
|
511
567
|
# -- bookkeeping -------------------------------------------------------
|
|
512
568
|
|
|
569
|
+
def _resource_abort_reason(self) -> str | None:
|
|
570
|
+
"""Non-None when the agent sandbox's last resource sample crossed
|
|
571
|
+
the disk_abort threshold (the worker classifies; the level rides on
|
|
572
|
+
the event)."""
|
|
573
|
+
sample = self._last_resources.get("agent")
|
|
574
|
+
if sample and sample.get("level") == "abort":
|
|
575
|
+
return (
|
|
576
|
+
f"sandbox disk exhausted: {sample.get('disk_used_pct')}% of the workspace "
|
|
577
|
+
f"filesystem is used (limits.disk_abort={self.config.limits.disk_abort}%)"
|
|
578
|
+
)
|
|
579
|
+
return None
|
|
580
|
+
|
|
513
581
|
def _check_cancelled_and_clock(self, run_id: str, deadline: float) -> None:
|
|
514
582
|
if self.store.get_run(run_id).state == "cancelled":
|
|
515
583
|
raise StateError(f"run {run_id} was cancelled")
|
|
@@ -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
|
+
)
|
|
@@ -27,7 +27,7 @@ from collections import deque
|
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
29
|
import sbxloop
|
|
30
|
-
from sbxloop.config import WorkerTransport
|
|
30
|
+
from sbxloop.config import Limits, WorkerTransport
|
|
31
31
|
from sbxloop.errors import SbxError, WorkerError, WorkerTimeoutError
|
|
32
32
|
from sbxloop.events import EventBus
|
|
33
33
|
from sbxloop.sbx.models import ExecResult
|
|
@@ -61,6 +61,8 @@ class WorkerClient:
|
|
|
61
61
|
python: str = DEFAULT_PYTHON,
|
|
62
62
|
poll_interval: float = 2.0,
|
|
63
63
|
grace_s: float = 60.0,
|
|
64
|
+
role: str | None = None,
|
|
65
|
+
limits: Limits | None = None,
|
|
64
66
|
) -> None:
|
|
65
67
|
self.sandbox = sandbox
|
|
66
68
|
self.bus = bus or EventBus()
|
|
@@ -68,6 +70,11 @@ class WorkerClient:
|
|
|
68
70
|
self.python = python
|
|
69
71
|
self.poll_interval = poll_interval
|
|
70
72
|
self.grace_s = grace_s
|
|
73
|
+
# Sandbox role for enriching resource telemetry (the worker doesn't
|
|
74
|
+
# know which sandbox it lives in), and guardrail thresholds to pass
|
|
75
|
+
# through to the worker's heartbeat sampler.
|
|
76
|
+
self.role = role
|
|
77
|
+
self.limits = limits
|
|
71
78
|
|
|
72
79
|
# -- install -----------------------------------------------------------
|
|
73
80
|
|
|
@@ -266,6 +273,15 @@ class WorkerClient:
|
|
|
266
273
|
# process itself chdirs there — agent SDK sessions inherit it.
|
|
267
274
|
if job.cwd:
|
|
268
275
|
argv += ["--cwd", job.cwd]
|
|
276
|
+
if self.limits is not None:
|
|
277
|
+
argv += [
|
|
278
|
+
"--disk-warn",
|
|
279
|
+
str(self.limits.disk_warn),
|
|
280
|
+
"--disk-abort",
|
|
281
|
+
str(self.limits.disk_abort),
|
|
282
|
+
"--mem-warn",
|
|
283
|
+
str(self.limits.mem_warn),
|
|
284
|
+
]
|
|
269
285
|
# sbx injects secrets through the sandbox session/profile machinery;
|
|
270
286
|
# a bare exec'd process may not see them. Run the worker under a
|
|
271
287
|
# login shell so the sandbox environment is fully loaded.
|
|
@@ -338,6 +354,11 @@ class WorkerClient:
|
|
|
338
354
|
Event.now(EventTypes.WORKER_STDOUT, job.run_id, job_id=job.job_id, line=line)
|
|
339
355
|
)
|
|
340
356
|
return
|
|
357
|
+
if self.role is not None and event.type in (
|
|
358
|
+
EventTypes.SANDBOX_RESOURCES,
|
|
359
|
+
EventTypes.SANDBOX_RESOURCES_WARNING,
|
|
360
|
+
):
|
|
361
|
+
event.data.setdefault("role", self.role)
|
|
341
362
|
self.bus.publish(event)
|
|
342
363
|
|
|
343
364
|
# -- poll transport ----------------------------------------------------
|
|
Binary file
|
|
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
|