maf-sandbox-codeact 0.9.2__tar.gz → 0.10.0__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.
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/PKG-INFO +6 -8
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/README.md +4 -6
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/pyproject.toml +2 -2
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/pyproject.toml.orig +2 -2
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/src/maf_sandbox_codeact/_tool.py +166 -79
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/LICENSE +0 -0
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/src/maf_sandbox_codeact/__init__.py +0 -0
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/src/maf_sandbox_codeact/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: maf-sandbox-codeact
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: CodeAct as a Microsoft Agent Framework tool — the model writes a short Python program and it runs inside a sandbox — written against maf-sandbox so it runs on any sandbox backend.
|
|
5
5
|
Keywords: codeact,code-execution,sandbox,agent-framework,microsoft-agent-framework
|
|
6
6
|
Author: SOKOLAI BV
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
17
|
Classifier: Topic :: Software Development :: Interpreters
|
|
18
|
-
Requires-Dist: maf-sandbox>=0.
|
|
18
|
+
Requires-Dist: maf-sandbox>=0.30.0,<0.32
|
|
19
19
|
Requires-Dist: agent-framework-core>=1.13.0,<2
|
|
20
20
|
Requires-Python: >=3.12, <3.15
|
|
21
21
|
Project-URL: Homepage, https://www.sokol.ai
|
|
@@ -128,19 +128,17 @@ Naming a host is a real widening, and worth naming as such: this sandbox runs mo
|
|
|
128
128
|
|
|
129
129
|
**The host authorises every way in, and initiates only one of them.** With `file_store` wired, caller-selected files are written in before the program runs — deliberately, and constrained to the caller's listing, so the model cannot widen the set. What that content *is* remains the host's to know: a file in the store may itself carry text from somewhere untrusted, and a program that parses it is running on input the sandbox did not vet. An allowlisted host is the other way, and the *program* initiates that one — whatever it fetches is input nobody chose in advance. Wire no store, no allowlist and no registry, and nothing gets in but the source the model wrote.
|
|
130
130
|
|
|
131
|
-
**The tool declares no `source_integrity
|
|
131
|
+
**The tool declares no `source_integrity`, in either rendering.** The library's default is `"trusted"`, and what has to hold for it is that the result derives from wholly trusted input. It does not here: what comes back is whatever a model-written `print(...)` chose to emit. Undeclared, MAF's information-flow tracker applies its untrusted default — the fail-safe direction, and the honest one. A declaration would not sit under that default as a floor, either: FIDES reads `source_integrity` as an override and drops the input-label join entirely.
|
|
132
132
|
|
|
133
|
-
**`withhold_guest_output=True` changes
|
|
133
|
+
**`withhold_guest_output=True` changes what comes back, and not what it claims.** The result is then the exit code, the size of each stream, which of the declared outputs landed — by the name the model itself declared — and one sentence naming the route. No guest-authored *text* survives into it. What does survive is chosen by the program all the same, so the declaration stays absent: withholding removes the guest's text from the result, not the guest from the result's derivation. What it is for is the rendering itself — guest-authored text never reaches the transcript, a host's logs, or anything else downstream of the call, and that holds whether or not a host runs information-flow middleware. Content still reaches the model by the road the option leaves open: the program writes it to a declared output, that lands in the sink, and the host's own already-classified file-reading path serves it back.
|
|
134
134
|
|
|
135
135
|
The rendering follows the transport. Off the host-tool-call transport you get the exit code and a size for `stdout` and for `stderr` separately. On it, the launcher merges the program's stderr into its stdout, so there is one `output` size — and `stderr` is the host's field there, so its note about the run is surfaced whole under `note:`. Withholding that note would report a run whose output was dropped for its size as one that printed nothing.
|
|
136
136
|
|
|
137
|
-
Be precise about what that buys. The prose and the shape are this package's, and the artifact names are the model's own — but what fills them is the program's to choose. An exit status is 8 bits. Each stream's size is a few more, chosen by padding. And **each declared output is one further bit**: the program decides whether to write it, and the result says of every declared name whether it landed, so a call declaring the default eight names carries eight more. So
|
|
137
|
+
Be precise about what that buys. The prose and the shape are this package's, and the artifact names are the model's own — but what fills them is the program's to choose. An exit status is 8 bits. Each stream's size is a few more, chosen by padding. And **each declared output is one further bit**: the program decides whether to write it, and the result says of every declared name whether it landed, so a call declaring the default eight names carries eight more. So what stops crossing is guest-authored *text*, not every guest-chosen *bit*. That is a narrow, per-call channel rather than the open one a rendered `stdout` is, and it is the honest limit. A host that has to close it should not attach this workload at all.
|
|
138
138
|
|
|
139
139
|
A "size" here is the UTF-8 length of the text the stream decoded to, not the byte count the program wrote. `ExecResult` states no decoding contract ([#465](https://github.com/sokolaidev/maf-extensions/issues/465)), so a backend replacing an undecodable byte changes the number: the same program writing four bytes, `b"ok\xff\xfe"`, reports 8 on Docker and 5 on ACA Sandboxes. Neither is 4, and nothing here can recover it — `ExecResult` carries neither the bytes nor a length.
|
|
140
140
|
|
|
141
|
-
**The sink's `display` is not rendered in this mode
|
|
142
|
-
|
|
143
|
-
A wired host-tool registry keeps its say. `HostToolAggregate.result_integrity` is the weakest level over every registered source — an unstamped tool has already failed safe into it — and where it says `untrusted` this tool declares nothing, withheld or not. Withholding is about this kind's own rendering; where a host tool's data came from is core's fold to make.
|
|
141
|
+
**The sink's `display` is not rendered in this mode.** `OutputSink.deliver` is handed an `Artifact` whose `content` is the guest's bytes, and no protocol rule keeps `display` independent of it — a sink that quoted any of that content would put guest-authored text straight back into the result. Withheld, the saved files are named by the spelling the model itself declared, which costs the sink's own detail and needs no promise from the host to stay honest.
|
|
144
142
|
|
|
145
143
|
The mode is required to be `CodeactOutputs.DECLARED` and both other pairings are refused at construction, each for its own reason. Under `NONE` there is nowhere for content to go, so no call could return anything the model can use. Under `MANIFEST` the *program* names its own files in `outputs.json`, and this kind renders those names back into the result — a guest-chosen string inside a result claiming to hold none, which is the fail-open shape the option exists to close.
|
|
146
144
|
|
|
@@ -103,19 +103,17 @@ Naming a host is a real widening, and worth naming as such: this sandbox runs mo
|
|
|
103
103
|
|
|
104
104
|
**The host authorises every way in, and initiates only one of them.** With `file_store` wired, caller-selected files are written in before the program runs — deliberately, and constrained to the caller's listing, so the model cannot widen the set. What that content *is* remains the host's to know: a file in the store may itself carry text from somewhere untrusted, and a program that parses it is running on input the sandbox did not vet. An allowlisted host is the other way, and the *program* initiates that one — whatever it fetches is input nobody chose in advance. Wire no store, no allowlist and no registry, and nothing gets in but the source the model wrote.
|
|
105
105
|
|
|
106
|
-
**The tool declares no `source_integrity
|
|
106
|
+
**The tool declares no `source_integrity`, in either rendering.** The library's default is `"trusted"`, and what has to hold for it is that the result derives from wholly trusted input. It does not here: what comes back is whatever a model-written `print(...)` chose to emit. Undeclared, MAF's information-flow tracker applies its untrusted default — the fail-safe direction, and the honest one. A declaration would not sit under that default as a floor, either: FIDES reads `source_integrity` as an override and drops the input-label join entirely.
|
|
107
107
|
|
|
108
|
-
**`withhold_guest_output=True` changes
|
|
108
|
+
**`withhold_guest_output=True` changes what comes back, and not what it claims.** The result is then the exit code, the size of each stream, which of the declared outputs landed — by the name the model itself declared — and one sentence naming the route. No guest-authored *text* survives into it. What does survive is chosen by the program all the same, so the declaration stays absent: withholding removes the guest's text from the result, not the guest from the result's derivation. What it is for is the rendering itself — guest-authored text never reaches the transcript, a host's logs, or anything else downstream of the call, and that holds whether or not a host runs information-flow middleware. Content still reaches the model by the road the option leaves open: the program writes it to a declared output, that lands in the sink, and the host's own already-classified file-reading path serves it back.
|
|
109
109
|
|
|
110
110
|
The rendering follows the transport. Off the host-tool-call transport you get the exit code and a size for `stdout` and for `stderr` separately. On it, the launcher merges the program's stderr into its stdout, so there is one `output` size — and `stderr` is the host's field there, so its note about the run is surfaced whole under `note:`. Withholding that note would report a run whose output was dropped for its size as one that printed nothing.
|
|
111
111
|
|
|
112
|
-
Be precise about what that buys. The prose and the shape are this package's, and the artifact names are the model's own — but what fills them is the program's to choose. An exit status is 8 bits. Each stream's size is a few more, chosen by padding. And **each declared output is one further bit**: the program decides whether to write it, and the result says of every declared name whether it landed, so a call declaring the default eight names carries eight more. So
|
|
112
|
+
Be precise about what that buys. The prose and the shape are this package's, and the artifact names are the model's own — but what fills them is the program's to choose. An exit status is 8 bits. Each stream's size is a few more, chosen by padding. And **each declared output is one further bit**: the program decides whether to write it, and the result says of every declared name whether it landed, so a call declaring the default eight names carries eight more. So what stops crossing is guest-authored *text*, not every guest-chosen *bit*. That is a narrow, per-call channel rather than the open one a rendered `stdout` is, and it is the honest limit. A host that has to close it should not attach this workload at all.
|
|
113
113
|
|
|
114
114
|
A "size" here is the UTF-8 length of the text the stream decoded to, not the byte count the program wrote. `ExecResult` states no decoding contract ([#465](https://github.com/sokolaidev/maf-extensions/issues/465)), so a backend replacing an undecodable byte changes the number: the same program writing four bytes, `b"ok\xff\xfe"`, reports 8 on Docker and 5 on ACA Sandboxes. Neither is 4, and nothing here can recover it — `ExecResult` carries neither the bytes nor a length.
|
|
115
115
|
|
|
116
|
-
**The sink's `display` is not rendered in this mode
|
|
117
|
-
|
|
118
|
-
A wired host-tool registry keeps its say. `HostToolAggregate.result_integrity` is the weakest level over every registered source — an unstamped tool has already failed safe into it — and where it says `untrusted` this tool declares nothing, withheld or not. Withholding is about this kind's own rendering; where a host tool's data came from is core's fold to make.
|
|
116
|
+
**The sink's `display` is not rendered in this mode.** `OutputSink.deliver` is handed an `Artifact` whose `content` is the guest's bytes, and no protocol rule keeps `display` independent of it — a sink that quoted any of that content would put guest-authored text straight back into the result. Withheld, the saved files are named by the spelling the model itself declared, which costs the sink's own detail and needs no promise from the host to stay honest.
|
|
119
117
|
|
|
120
118
|
The mode is required to be `CodeactOutputs.DECLARED` and both other pairings are refused at construction, each for its own reason. Under `NONE` there is nowhere for content to go, so no call could return anything the model can use. Under `MANIFEST` the *program* names its own files in `outputs.json`, and this kind renders those names back into the result — a guest-chosen string inside a result claiming to hold none, which is the fail-open shape the option exists to close.
|
|
121
119
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "maf-sandbox-codeact"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
4
4
|
description = "CodeAct as a Microsoft Agent Framework tool — the model writes a short Python program and it runs inside a sandbox — written against maf-sandbox so it runs on any sandbox backend."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12,<3.15"
|
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Topic :: Software Development :: Interpreters",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"maf-sandbox>=0.
|
|
27
|
+
"maf-sandbox>=0.30.0,<0.32",
|
|
28
28
|
"agent-framework-core>=1.13.0,<2",
|
|
29
29
|
]
|
|
30
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "maf-sandbox-codeact"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.0"
|
|
4
4
|
description = "CodeAct as a Microsoft Agent Framework tool — the model writes a short Python program and it runs inside a sandbox — written against maf-sandbox so it runs on any sandbox backend."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12,<3.15"
|
|
@@ -35,7 +35,7 @@ dependencies = [
|
|
|
35
35
|
# import, and that one fails at the call, where no import check is looking. The number
|
|
36
36
|
# itself is below, and `scripts/set_dependents_range.py` moves it — so this says which
|
|
37
37
|
# release the floor is for, never which release that is.
|
|
38
|
-
"maf-sandbox>=0.
|
|
38
|
+
"maf-sandbox>=0.30.0,<0.32",
|
|
39
39
|
# The `@tool` decorator. MAF is a genuine dependency of the tool itself, not of the host
|
|
40
40
|
# application.
|
|
41
41
|
"agent-framework-core>=1.13.0,<2",
|
|
@@ -50,16 +50,21 @@ from maf_sandbox import (
|
|
|
50
50
|
SandboxProgramTimeout,
|
|
51
51
|
SandboxRouter,
|
|
52
52
|
SandboxSpec,
|
|
53
|
-
SourceIntegrity,
|
|
54
53
|
TransferLimits,
|
|
55
54
|
collect_outputs,
|
|
55
|
+
echoed_name,
|
|
56
56
|
error_detail,
|
|
57
57
|
guest_run_layout,
|
|
58
58
|
host_tool_calls_over_exec,
|
|
59
59
|
host_tool_shim,
|
|
60
60
|
validate_artifact_name,
|
|
61
61
|
)
|
|
62
|
-
from maf_sandbox.maf import
|
|
62
|
+
from maf_sandbox.maf import (
|
|
63
|
+
SandboxToolSession,
|
|
64
|
+
hidden_content_candidates,
|
|
65
|
+
positions_holding_hidden_content,
|
|
66
|
+
sandboxed_tool,
|
|
67
|
+
)
|
|
63
68
|
|
|
64
69
|
if TYPE_CHECKING:
|
|
65
70
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
@@ -82,10 +87,10 @@ EXECUTE_CODE_TOOL_NAME = "execute_code"
|
|
|
82
87
|
#: The sandbox kind this workload asks for.
|
|
83
88
|
CODEACT_KIND = "codeact"
|
|
84
89
|
|
|
85
|
-
#: Where every
|
|
90
|
+
#: Where every call's directory is created — a dedicated root rather than the image's own tree.
|
|
86
91
|
_WORK_DIR = "/maf-sandbox/work"
|
|
87
92
|
|
|
88
|
-
#: One fixed name inside each
|
|
93
|
+
#: One fixed name inside each call's own directory.
|
|
89
94
|
_PROGRAM_FILENAME = "program.py"
|
|
90
95
|
|
|
91
96
|
#: What this kind needs to reach for ``execute_code`` to work at all — nothing, because the
|
|
@@ -93,6 +98,11 @@ _PROGRAM_FILENAME = "program.py"
|
|
|
93
98
|
#: rather than an omission; :func:`codeact_sandbox_spec` has why it is fixed here.
|
|
94
99
|
_KIND_EGRESS: tuple[str, ...] = ()
|
|
95
100
|
|
|
101
|
+
#: The arguments a caller names files in, which is what a refusal about one of them points
|
|
102
|
+
#: at and what provenance is asked about.
|
|
103
|
+
_FILES_ARGUMENT = "files"
|
|
104
|
+
_OUTPUTS_ARGUMENT = "outputs"
|
|
105
|
+
|
|
96
106
|
#: Where a ``MANIFEST``-mode program says what it produced.
|
|
97
107
|
_MANIFEST_FILENAME = "outputs.json"
|
|
98
108
|
_MANIFEST_OUTPUTS_KEY = "outputs"
|
|
@@ -250,11 +260,11 @@ def make_codeact_tools(
|
|
|
250
260
|
outputs: How a program's output files are named. See :class:`CodeactOutputs`.
|
|
251
261
|
withhold_guest_output: Keep what the program printed out of the tool result, and answer
|
|
252
262
|
with sizes and the model's own declared names instead. No guest-authored text
|
|
253
|
-
survives into the result
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
263
|
+
survives into the result — but the values that replace it were still chosen by a
|
|
264
|
+
program the model wrote, so this changes what the result *holds* and not where it
|
|
265
|
+
came from: the tool declares no ``source_integrity`` either way. Requires
|
|
266
|
+
:data:`CodeactOutputs.DECLARED`: the one mode where content can still reach the
|
|
267
|
+
model and no guest-chosen name reaches the result.
|
|
258
268
|
|
|
259
269
|
A size here is the UTF-8 length of the text the stream came back as, not the count
|
|
260
270
|
of bytes the program wrote: ``ExecResult`` states no decoding contract, so a
|
|
@@ -268,17 +278,16 @@ def make_codeact_tools(
|
|
|
268
278
|
``note:``, since withholding it would report a dropped output as a program that
|
|
269
279
|
printed nothing.
|
|
270
280
|
|
|
271
|
-
**What
|
|
272
|
-
the artifact names are the model's own — but what fills them is the program's
|
|
273
|
-
choose, and it is a channel rather than a leak-free boundary. The exit status is
|
|
274
|
-
bits; each stream's size is a few more, chosen by padding; and **each declared
|
|
281
|
+
**What withholding gets you, exactly.** The prose and the shape are this package's,
|
|
282
|
+
and the artifact names are the model's own — but what fills them is the program's
|
|
283
|
+
to choose, and it is a channel rather than a leak-free boundary. The exit status is
|
|
284
|
+
8 bits; each stream's size is a few more, chosen by padding; and **each declared
|
|
275
285
|
output is one further bit**, since the program decides whether to write it and the
|
|
276
286
|
result says of every declared name whether it landed — up to ``files_out.max_files``
|
|
277
|
-
of them. So
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
:func:`_format_landed`.
|
|
287
|
+
of them. So what stops crossing is guest-authored *text*, not every guest-chosen
|
|
288
|
+
*bit* — a narrow per-call channel rather than the open one a rendered ``stdout`` is,
|
|
289
|
+
and a host that must close it should not attach this workload at all. The sink's
|
|
290
|
+
``display`` is deliberately *not* rendered here — see :func:`_format_landed`.
|
|
282
291
|
outbound_max_confidentiality: The host's cap for tools that carry something out, in the
|
|
283
292
|
host's own vocabulary. Off by default and written only when something can actually
|
|
284
293
|
leave: an artifact landing in the sink, a host tool that carries something out, or
|
|
@@ -457,31 +466,18 @@ def make_codeact_tools(
|
|
|
457
466
|
name=EXECUTE_CODE_TOOL_NAME,
|
|
458
467
|
approval_mode="always_require" if approval_gated else "never_require",
|
|
459
468
|
also_carries_out=registry_carries_out,
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
source_integrity=
|
|
469
|
+
# Never declared, withheld or not. What comes back is whatever a model-written
|
|
470
|
+
# `print(...)` chose to emit; withheld it is an exit status, two sizes and a presence
|
|
471
|
+
# bit per declared output, every one of them chosen by a program the model wrote. A
|
|
472
|
+
# declaration replaces the framework's input-label join rather than flooring it, so
|
|
473
|
+
# declaring anything here would tell a host's middleware to disregard the input side.
|
|
474
|
+
source_integrity=None,
|
|
466
475
|
outbound_max_confidentiality=outbound_max_confidentiality,
|
|
467
476
|
output_sink=output_sink,
|
|
468
477
|
logger=logger,
|
|
469
478
|
)
|
|
470
479
|
|
|
471
480
|
|
|
472
|
-
def _withheld_integrity(surface: HostToolAggregate | None) -> SourceIntegrity | None:
|
|
473
|
-
"""What a withholding tool may declare, once the registry has had its say.
|
|
474
|
-
|
|
475
|
-
Withholding is about this kind's own rendering, and a registry's sources are core's fold to
|
|
476
|
-
make: ``result_integrity`` is the weakest level over every registered source, and an
|
|
477
|
-
unstamped tool has already failed safe into it. A registry with no opinion leaves the
|
|
478
|
-
workload's own, which withholding has earned; one that says untrusted keeps it.
|
|
479
|
-
"""
|
|
480
|
-
if surface is not None and surface.result_integrity is SourceIntegrity.UNTRUSTED:
|
|
481
|
-
return None
|
|
482
|
-
return SourceIntegrity.TRUSTED
|
|
483
|
-
|
|
484
|
-
|
|
485
481
|
def _effective_egress(extra: Sequence[str]) -> tuple[str, ...]:
|
|
486
482
|
"""The union of what this kind needs and what the deployment added, in that order.
|
|
487
483
|
|
|
@@ -898,6 +894,10 @@ async def _execute(
|
|
|
898
894
|
withhold: bool,
|
|
899
895
|
) -> str:
|
|
900
896
|
"""One ``execute_code`` call: share, run, and collect."""
|
|
897
|
+
# Taken before anything here awaits, and carried to every name check in this call: the
|
|
898
|
+
# framework's accessor is not scoped to the call, so a lookup made after the run — the
|
|
899
|
+
# manifest's, above all — may find nothing left to answer with.
|
|
900
|
+
rewritten = hidden_content_candidates()
|
|
901
901
|
# Scope and thread come from the host's request context, never from model input.
|
|
902
902
|
key = session.key()
|
|
903
903
|
if isinstance(key, str):
|
|
@@ -922,16 +922,17 @@ async def _execute(
|
|
|
922
922
|
)
|
|
923
923
|
|
|
924
924
|
# Chosen here rather than after `acquire`, so that a declared name can be judged against
|
|
925
|
-
# the guest path it will actually become — the prefix is
|
|
925
|
+
# the guest path it will actually become — the prefix is the id core allocates for the
|
|
926
|
+
# call, derived below rather than counted.
|
|
926
927
|
call_directory = session.guest_call_path()
|
|
927
|
-
|
|
928
|
-
# Where the model's own files live, relative to `work_dir`: the
|
|
928
|
+
call_id = call_directory.rsplit("/", 1)[-1]
|
|
929
|
+
# Where the model's own files live, relative to `work_dir`: the call directory itself, or
|
|
929
930
|
# the work subdirectory of it when the transport owns the run. Everything addressed by a
|
|
930
931
|
# name a model chose is built from this — what is shared in, what the manifest is read
|
|
931
|
-
# from, and what is collected out — so the three cannot disagree about one
|
|
932
|
+
# from, and what is collected out — so the three cannot disagree about one call's layout.
|
|
932
933
|
# It is longer when calling a host tool, which is why the name checks below take it rather than
|
|
933
|
-
# `
|
|
934
|
-
guest_prefix = f"{
|
|
934
|
+
# `call_id`: five more bytes of the 255 a guest path gets, spent before the name is.
|
|
935
|
+
guest_prefix = f"{call_id}/{WORK_DIRECTORY}" if host_tool_call is not None else call_id
|
|
935
936
|
|
|
936
937
|
names: list[str] = []
|
|
937
938
|
if outputs is CodeactOutputs.DECLARED:
|
|
@@ -941,6 +942,9 @@ async def _execute(
|
|
|
941
942
|
reserved=reserved,
|
|
942
943
|
guest_prefix=guest_prefix,
|
|
943
944
|
normalization=_normalization(session),
|
|
945
|
+
named_by=_OUTPUTS_ARGUMENT,
|
|
946
|
+
argument=_OUTPUTS_ARGUMENT,
|
|
947
|
+
candidates=rewritten,
|
|
944
948
|
)
|
|
945
949
|
if isinstance(checked, str):
|
|
946
950
|
return checked
|
|
@@ -967,7 +971,7 @@ async def _execute(
|
|
|
967
971
|
return over_cap
|
|
968
972
|
if store is not None:
|
|
969
973
|
resolved = await _resolve_listed_files(
|
|
970
|
-
session, store, files, reserved=reserved, withhold=withhold
|
|
974
|
+
session, store, files, reserved=reserved, withhold=withhold, candidates=rewritten
|
|
971
975
|
)
|
|
972
976
|
if isinstance(resolved, str):
|
|
973
977
|
return resolved
|
|
@@ -981,9 +985,9 @@ async def _execute(
|
|
|
981
985
|
return sandbox
|
|
982
986
|
|
|
983
987
|
# The session owns this path, and `sandboxed_tool` reclaims it when the call returns.
|
|
984
|
-
# Built before anything is written, because it decides where everything goes. A
|
|
988
|
+
# Built before anything is written, because it decides where everything goes. A call that
|
|
985
989
|
# calls a host tool is two directories — the model's files in `work`, the program and the shim
|
|
986
|
-
# in the transport's — and one that does not is the
|
|
990
|
+
# in the transport's — and one that does not is the call directory flat, which is what a kind
|
|
987
991
|
# writing no shim has always been. The program name and the interpreter are passed rather
|
|
988
992
|
# than defaulted, so this kind's constants and the transport's cannot drift apart.
|
|
989
993
|
layout = (
|
|
@@ -1095,7 +1099,14 @@ async def _execute(
|
|
|
1095
1099
|
# a program that caught its own error and wrote the diagnosis into one.
|
|
1096
1100
|
return report
|
|
1097
1101
|
collected = await _collect(
|
|
1098
|
-
session,
|
|
1102
|
+
session,
|
|
1103
|
+
sandbox,
|
|
1104
|
+
guest_prefix,
|
|
1105
|
+
outputs,
|
|
1106
|
+
names,
|
|
1107
|
+
reserved,
|
|
1108
|
+
withhold=withhold,
|
|
1109
|
+
candidates=rewritten,
|
|
1099
1110
|
)
|
|
1100
1111
|
return f"{report}\n\n{collected}" if collected else report
|
|
1101
1112
|
|
|
@@ -1110,6 +1121,7 @@ async def _resolve_listed_files(
|
|
|
1110
1121
|
*,
|
|
1111
1122
|
reserved: Mapping[str, str],
|
|
1112
1123
|
withhold: bool = False,
|
|
1124
|
+
candidates: frozenset[str] | None = None,
|
|
1113
1125
|
) -> list[str] | str:
|
|
1114
1126
|
"""Match each requested name against the caller's listing, or answer with the refusal.
|
|
1115
1127
|
|
|
@@ -1119,6 +1131,12 @@ async def _resolve_listed_files(
|
|
|
1119
1131
|
"""
|
|
1120
1132
|
if not files:
|
|
1121
1133
|
return []
|
|
1134
|
+
# Asked before the first await, not beside the loop that uses it: the framework's accessor
|
|
1135
|
+
# is not scoped to the call, so every suspension before asking is a chance for the answer
|
|
1136
|
+
# to come back empty. See `positions_holding_hidden_content`.
|
|
1137
|
+
rewritten = positions_holding_hidden_content(
|
|
1138
|
+
files, argument=_FILES_ARGUMENT, candidates=candidates
|
|
1139
|
+
)
|
|
1122
1140
|
listing = await session.list_files(store)
|
|
1123
1141
|
if isinstance(listing, str):
|
|
1124
1142
|
# The host's own sentence about its store. Withheld it is dropped for the reason the
|
|
@@ -1129,24 +1147,27 @@ async def _resolve_listed_files(
|
|
|
1129
1147
|
return listing
|
|
1130
1148
|
known = set(listing)
|
|
1131
1149
|
resolved: list[str] = []
|
|
1132
|
-
for name in files:
|
|
1150
|
+
for position, name in enumerate(files):
|
|
1151
|
+
at = f"files[{position}]"
|
|
1152
|
+
hidden = position in rewritten
|
|
1153
|
+
named = echoed_name(name, at=at, hidden=hidden)
|
|
1133
1154
|
try:
|
|
1134
|
-
validate_artifact_name(name)
|
|
1155
|
+
validate_artifact_name(name, at=at, hidden=hidden)
|
|
1135
1156
|
except SandboxArtifactNameInvalid as exc:
|
|
1136
1157
|
# The validator's own sentence, which names the rule that was broken: a fixed
|
|
1137
1158
|
# message listing two of its rules tells a caller refused for a backslash or a
|
|
1138
1159
|
# control character that its name satisfies everything the tool asked for. The
|
|
1139
1160
|
# listing is still not echoed — that would invite a retry with another spelling.
|
|
1140
|
-
return f"Error: {
|
|
1161
|
+
return f"Error: {named} cannot be shared — {exc}"
|
|
1141
1162
|
if name in reserved:
|
|
1142
|
-
return f"Error: {
|
|
1143
|
-
refusal = _inside_a_reserved_file(name, reserved, action="shared")
|
|
1163
|
+
return f"Error: {named} cannot be shared — {reserved[name]}."
|
|
1164
|
+
refusal = _inside_a_reserved_file(name, reserved, action="shared", at=at, hidden=hidden)
|
|
1144
1165
|
if refusal is not None:
|
|
1145
1166
|
return refusal
|
|
1146
1167
|
if name in resolved:
|
|
1147
1168
|
# One read and one write per name. Repeating one buys the caller nothing and
|
|
1148
1169
|
# multiplies both, which is the cheapest way to amplify against the byte ceilings.
|
|
1149
|
-
return f"Error: {
|
|
1170
|
+
return f"Error: {named} was listed twice."
|
|
1150
1171
|
if name not in known:
|
|
1151
1172
|
logger.warning(
|
|
1152
1173
|
"execute_code: %r is not in this tool's file store listing (%d file(s) visible) "
|
|
@@ -1155,7 +1176,7 @@ async def _resolve_listed_files(
|
|
|
1155
1176
|
len(listing),
|
|
1156
1177
|
)
|
|
1157
1178
|
return (
|
|
1158
|
-
f"Error: {
|
|
1179
|
+
f"Error: {named} is not in this tool's file listing, so it was not shared. "
|
|
1159
1180
|
f"{_listing_hint(name, listing, withhold=withhold)}"
|
|
1160
1181
|
)
|
|
1161
1182
|
resolved.append(name)
|
|
@@ -1172,8 +1193,8 @@ def _listing_hint(name: str, listing: list[str], *, withhold: bool = False) -> s
|
|
|
1172
1193
|
``withhold`` names none of them. A store's filenames are the host's to supply through
|
|
1173
1194
|
``list_files`` and carry no integrity contract of their own — an agent that saved something
|
|
1174
1195
|
it fetched may have named it from that content — so echoing up to
|
|
1175
|
-
:data:`_LISTING_HINT_MAX` of them would put unclassified text into a result
|
|
1176
|
-
|
|
1196
|
+
:data:`_LISTING_HINT_MAX` of them would put unclassified text into a result whose whole
|
|
1197
|
+
point is to hold none, for a name the model never asked about.
|
|
1177
1198
|
"""
|
|
1178
1199
|
if not listing:
|
|
1179
1200
|
return "This tool's listing is empty — no files were shared with it."
|
|
@@ -1221,7 +1242,9 @@ async def _read_listed_files(
|
|
|
1221
1242
|
return read
|
|
1222
1243
|
|
|
1223
1244
|
|
|
1224
|
-
def _inside_a_reserved_file(
|
|
1245
|
+
def _inside_a_reserved_file(
|
|
1246
|
+
name: str, reserved: Mapping[str, str], *, action: str, at: str, hidden: bool
|
|
1247
|
+
) -> str | None:
|
|
1225
1248
|
"""Refuse a name that would have to live inside a reserved file, naming which one.
|
|
1226
1249
|
|
|
1227
1250
|
Every shipped backend creates parent directories for a nested write, so
|
|
@@ -1235,8 +1258,8 @@ def _inside_a_reserved_file(name: str, reserved: Mapping[str, str], *, action: s
|
|
|
1235
1258
|
if above is None:
|
|
1236
1259
|
return None
|
|
1237
1260
|
return (
|
|
1238
|
-
f"Error: {name
|
|
1239
|
-
f"every run's directory, so nothing can live inside it."
|
|
1261
|
+
f"Error: {echoed_name(name, at=at, hidden=hidden)} cannot be {action} — {above!r} is a "
|
|
1262
|
+
f"file name this tool reserves in every run's directory, so nothing can live inside it."
|
|
1240
1263
|
)
|
|
1241
1264
|
|
|
1242
1265
|
|
|
@@ -1334,6 +1357,9 @@ def _validated_output_names(
|
|
|
1334
1357
|
reserved: Mapping[str, str],
|
|
1335
1358
|
guest_prefix: str,
|
|
1336
1359
|
normalization: NameNormalization,
|
|
1360
|
+
named_by: str,
|
|
1361
|
+
argument: str | None = None,
|
|
1362
|
+
candidates: frozenset[str] | None = None,
|
|
1337
1363
|
) -> list[str] | str:
|
|
1338
1364
|
"""Settle every output name before the program runs, or answer with the refusal.
|
|
1339
1365
|
|
|
@@ -1341,6 +1367,9 @@ def _validated_output_names(
|
|
|
1341
1367
|
with its run prefix, and the delivered name after normalization — so that a refusal cannot
|
|
1342
1368
|
arrive a whole run late. That function stays the authority: if the two disagree, this one
|
|
1343
1369
|
is wrong, and the cost is the late refusal rather than a name reaching a host.
|
|
1370
|
+
|
|
1371
|
+
``named_by`` says where the names came from — the ``outputs`` argument or the manifest —
|
|
1372
|
+
so a refusal can point at the one it means without quoting a value it should not.
|
|
1344
1373
|
"""
|
|
1345
1374
|
if len(names) > max_files:
|
|
1346
1375
|
return (
|
|
@@ -1348,8 +1377,15 @@ def _validated_output_names(
|
|
|
1348
1377
|
f"{max_files} per call."
|
|
1349
1378
|
)
|
|
1350
1379
|
prefix = f"{guest_prefix}/"
|
|
1351
|
-
seen: dict[str, str] = {}
|
|
1352
|
-
|
|
1380
|
+
seen: dict[str, tuple[str, str]] = {}
|
|
1381
|
+
# Asked of manifest names as well as of the model's own `outputs`, and that is not
|
|
1382
|
+
# belt-and-braces: `code` is a rewritten argument too, so a payload can reach the guest
|
|
1383
|
+
# in the program's own source and come back as a name the program chose to write.
|
|
1384
|
+
rewritten = positions_holding_hidden_content(names, argument=argument, candidates=candidates)
|
|
1385
|
+
for position, name in enumerate(names):
|
|
1386
|
+
at = f"{named_by}[{position}]"
|
|
1387
|
+
hidden = position in rewritten
|
|
1388
|
+
named = echoed_name(name, at=at, hidden=hidden)
|
|
1353
1389
|
# NFC is not length-non-increasing — 43 × U+0958 is 129 bytes declared and 258
|
|
1354
1390
|
# delivered — so the name to hold to the invariant is the one the sink will receive.
|
|
1355
1391
|
delivered = (
|
|
@@ -1357,25 +1393,25 @@ def _validated_output_names(
|
|
|
1357
1393
|
)
|
|
1358
1394
|
for spelling in (name, delivered, prefix + name):
|
|
1359
1395
|
try:
|
|
1360
|
-
validate_artifact_name(spelling)
|
|
1396
|
+
validate_artifact_name(spelling, at=at, hidden=hidden)
|
|
1361
1397
|
except SandboxArtifactNameInvalid as exc:
|
|
1362
|
-
return f"Error: {
|
|
1398
|
+
return f"Error: {named} cannot be saved — {exc}"
|
|
1363
1399
|
if name in reserved:
|
|
1364
|
-
return f"Error: {
|
|
1365
|
-
refusal = _inside_a_reserved_file(name, reserved, action="saved")
|
|
1400
|
+
return f"Error: {named} cannot be saved — {reserved[name]}."
|
|
1401
|
+
refusal = _inside_a_reserved_file(name, reserved, action="saved", at=at, hidden=hidden)
|
|
1366
1402
|
if refusal is not None:
|
|
1367
1403
|
return refusal
|
|
1368
1404
|
# `collect_outputs`' own key: NFC and case-folded, always, whatever the sink does
|
|
1369
1405
|
# about rewriting.
|
|
1370
1406
|
key = unicodedata.normalize("NFC", name).lower()
|
|
1371
1407
|
if key in seen:
|
|
1372
|
-
earlier = seen[key]
|
|
1408
|
+
earlier, earlier_named = seen[key]
|
|
1373
1409
|
return (
|
|
1374
|
-
f"Error: {
|
|
1410
|
+
f"Error: {named} and {earlier_named} are one file once saved"
|
|
1375
1411
|
if earlier != name
|
|
1376
|
-
else f"Error: {
|
|
1412
|
+
else f"Error: {named} was declared twice."
|
|
1377
1413
|
)
|
|
1378
|
-
seen[key] = name
|
|
1414
|
+
seen[key] = (name, named)
|
|
1379
1415
|
return list(names)
|
|
1380
1416
|
|
|
1381
1417
|
|
|
@@ -1388,6 +1424,7 @@ async def _collect(
|
|
|
1388
1424
|
reserved: Mapping[str, str],
|
|
1389
1425
|
*,
|
|
1390
1426
|
withhold: bool = False,
|
|
1427
|
+
candidates: frozenset[str] | None = None,
|
|
1391
1428
|
) -> str:
|
|
1392
1429
|
"""Land whatever this run produced, and say what happened — never raising into the model."""
|
|
1393
1430
|
sink = session.output_sink
|
|
@@ -1419,6 +1456,8 @@ async def _collect(
|
|
|
1419
1456
|
reserved=reserved,
|
|
1420
1457
|
guest_prefix=guest_prefix,
|
|
1421
1458
|
normalization=_normalization(session),
|
|
1459
|
+
named_by=_MANIFEST_FILENAME,
|
|
1460
|
+
candidates=candidates,
|
|
1422
1461
|
)
|
|
1423
1462
|
if isinstance(checked, str):
|
|
1424
1463
|
return checked
|
|
@@ -1447,7 +1486,14 @@ async def _collect(
|
|
|
1447
1486
|
except Exception as exc: # noqa: BLE001
|
|
1448
1487
|
logger.warning("execute_code: saving this run's files failed: %s", error_detail(exc))
|
|
1449
1488
|
return f"Error: the program ran but its files could not be saved. {_MAY_HAVE_LANDED}"
|
|
1450
|
-
return _format_landed(
|
|
1489
|
+
return _format_landed(
|
|
1490
|
+
landed,
|
|
1491
|
+
declared,
|
|
1492
|
+
withhold=withhold,
|
|
1493
|
+
named_by=_MANIFEST_FILENAME if outputs is CodeactOutputs.MANIFEST else _OUTPUTS_ARGUMENT,
|
|
1494
|
+
argument=None if outputs is CodeactOutputs.MANIFEST else _OUTPUTS_ARGUMENT,
|
|
1495
|
+
candidates=candidates,
|
|
1496
|
+
)
|
|
1451
1497
|
|
|
1452
1498
|
|
|
1453
1499
|
async def _read_manifest(
|
|
@@ -1531,7 +1577,13 @@ async def _read_manifest(
|
|
|
1531
1577
|
|
|
1532
1578
|
|
|
1533
1579
|
def _format_landed(
|
|
1534
|
-
landed: Sequence[LandedArtifact],
|
|
1580
|
+
landed: Sequence[LandedArtifact],
|
|
1581
|
+
declared: Sequence[str],
|
|
1582
|
+
*,
|
|
1583
|
+
withhold: bool = False,
|
|
1584
|
+
named_by: str = _OUTPUTS_ARGUMENT,
|
|
1585
|
+
argument: str | None = None,
|
|
1586
|
+
candidates: frozenset[str] | None = None,
|
|
1535
1587
|
) -> str:
|
|
1536
1588
|
"""What the model is told about the files: what landed, and what is absent.
|
|
1537
1589
|
|
|
@@ -1541,24 +1593,59 @@ def _format_landed(
|
|
|
1541
1593
|
Normalizing **both** sides is right whichever normalization the sink chose — under
|
|
1542
1594
|
``NameNormalization.NONE`` the two are already the same string.
|
|
1543
1595
|
|
|
1596
|
+
``argument`` names the parameter ``declared`` came from, where it came from one — the
|
|
1597
|
+
``outputs`` argument, never the manifest, which no caller spelled. It is what makes the
|
|
1598
|
+
provenance answer exact here rather than inferred, and it matters as much after the run as
|
|
1599
|
+
before it: without it a declared name equal to hidden content renders as a position, and a
|
|
1600
|
+
caller watching which way its own spelling comes back learns that the guess was right.
|
|
1601
|
+
|
|
1544
1602
|
``withhold`` drops ``display`` in favour of the name the model itself declared. The sink
|
|
1545
1603
|
composes ``display`` from an :class:`~maf_sandbox.Artifact` whose ``content`` is the guest's
|
|
1546
1604
|
bytes, and nothing in the protocol requires the two to be independent — so a sink that puts
|
|
1547
|
-
any of that content in the string would be putting guest-authored text into a result
|
|
1548
|
-
|
|
1549
|
-
|
|
1605
|
+
any of that content in the string would be putting guest-authored text back into a result
|
|
1606
|
+
rendered to hold none. Naming the declared spelling costs the sink's own detail and needs no
|
|
1607
|
+
promise from the host to stay honest.
|
|
1550
1608
|
"""
|
|
1551
1609
|
delivered = {unicodedata.normalize("NFC", item.name) for item in landed}
|
|
1552
1610
|
lines: list[str] = []
|
|
1611
|
+
# Answered once, above the two renderings that need it: a name that landed is reported as
|
|
1612
|
+
# surely as one that did not, and a name the framework substituted must not be repeated
|
|
1613
|
+
# either way. `withhold` makes no difference to that — it is the mode that renders least.
|
|
1614
|
+
rewritten = positions_holding_hidden_content(
|
|
1615
|
+
list(declared), argument=argument, candidates=candidates
|
|
1616
|
+
)
|
|
1617
|
+
position_of = {
|
|
1618
|
+
unicodedata.normalize("NFC", name): position for position, name in enumerate(declared)
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1553
1621
|
if landed:
|
|
1554
1622
|
lines.append("Saved:")
|
|
1555
1623
|
if withhold:
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1624
|
+
for position, name in enumerate(declared):
|
|
1625
|
+
if unicodedata.normalize("NFC", name) in delivered:
|
|
1626
|
+
# The name itself unless the framework put it there: this is a list of
|
|
1627
|
+
# files, not a refusal, so a name of the model's own is what it wants back.
|
|
1628
|
+
lines.append(
|
|
1629
|
+
f"- {named_by}[{position}]" if position in rewritten else f"- {name}"
|
|
1630
|
+
)
|
|
1559
1631
|
else:
|
|
1560
|
-
|
|
1561
|
-
|
|
1632
|
+
for item in landed:
|
|
1633
|
+
# The display carries the name inside it, so a substituted one is reported by
|
|
1634
|
+
# position instead. A name of the model's own keeps the size and the rest of it.
|
|
1635
|
+
position = position_of.get(unicodedata.normalize("NFC", item.name))
|
|
1636
|
+
lines.append(
|
|
1637
|
+
f"- {named_by}[{position}]"
|
|
1638
|
+
if position is not None and position in rewritten
|
|
1639
|
+
else f"- {item.display}"
|
|
1640
|
+
)
|
|
1641
|
+
# A name that produced no file is reported the way a refusal reports one: the caller's
|
|
1642
|
+
# spelling where it is the caller's, and the position where the framework put something
|
|
1643
|
+
# else there. It is the one line here that names a file which does not exist.
|
|
1644
|
+
missing = [
|
|
1645
|
+
echoed_name(name, at=f"{named_by}[{position}]", hidden=position in rewritten)
|
|
1646
|
+
for position, name in enumerate(declared)
|
|
1647
|
+
if unicodedata.normalize("NFC", name) not in delivered
|
|
1648
|
+
]
|
|
1562
1649
|
if missing:
|
|
1563
1650
|
lines.append(
|
|
1564
1651
|
f"Not written by the program, so not saved: {', '.join(sorted(missing))}. Write "
|
|
File without changes
|
{maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.10.0}/src/maf_sandbox_codeact/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|