maf-sandbox-codeact 0.9.2__tar.gz → 0.11.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.11.0}/PKG-INFO +10 -8
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.0}/README.md +8 -6
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.0}/pyproject.toml +2 -2
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.0}/pyproject.toml.orig +2 -2
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.0}/src/maf_sandbox_codeact/_tool.py +321 -160
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.0}/LICENSE +0 -0
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.0}/src/maf_sandbox_codeact/__init__.py +0 -0
- {maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.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.11.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.32.0,<0.33
|
|
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,21 @@ 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.** A `"trusted"` claim asks that the result not derive from input the framework has left unestablished, and this one does: 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
|
-
|
|
133
|
+
**What the untrusted default costs is the model's sight of the result, not the host's sinks.** FIDES hides an untrusted result by default: the item is replaced by a variable reference the model can pass to another tool without reading, and hidden content does not taint the conversation's integrity, so later tools run ungated. Turn hiding off and the result is visible, the conversation goes untrusted, and `PolicyEnforcementFunctionMiddleware` gates every later tool that has not opted in. A host gets one or the other. Two limits on the hiding half: it lapses once anything else has tainted the conversation, and it is about integrity only — a hidden item still contributes its confidentiality. Measured, with the full conditions, in [`docs/sandbox/information-flow.md`](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/information-flow.md).
|
|
134
|
+
|
|
135
|
+
**`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.
|
|
136
|
+
|
|
137
|
+
**Withheld, the result is two items rather than one string, and hiding can reach one without the other.** The call-derived half — the exit code, the sizes, the landed names — carries no label of its own, so it takes whatever label the call would otherwise have had; beside it sits the sentence naming the recovery route, labelled `trusted`, because nothing a call produced reaches it and it is emitted on every return path, refusals included. **Where that call resolves untrusted and the conversation is still clean** — `default_integrity` is the host's to move, and hiding is a first-taint protection that stops once anything has tainted the conversation — a FIDES host with hiding on reads the sentence while the numbers sit behind a variable reference, which is the point of the split: under one label the sentence went with the numbers it was there to explain, and a model got a hidden result and no way to act on it. Where either condition fails both items are simply readable, which is the same outcome one string had. The call-derived half stays unlabelled deliberately: a per-item label replaces the *whole* label, confidentiality included, and confidentiality values are the host's vocabulary rather than this package's to invent. A host wiring no information-flow middleware simply reads both items.
|
|
134
138
|
|
|
135
139
|
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
140
|
|
|
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
|
|
141
|
+
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
142
|
|
|
139
143
|
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
144
|
|
|
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.
|
|
145
|
+
**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
146
|
|
|
145
147
|
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
148
|
|
|
@@ -103,19 +103,21 @@ 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.** A `"trusted"` claim asks that the result not derive from input the framework has left unestablished, and this one does: 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
|
-
|
|
108
|
+
**What the untrusted default costs is the model's sight of the result, not the host's sinks.** FIDES hides an untrusted result by default: the item is replaced by a variable reference the model can pass to another tool without reading, and hidden content does not taint the conversation's integrity, so later tools run ungated. Turn hiding off and the result is visible, the conversation goes untrusted, and `PolicyEnforcementFunctionMiddleware` gates every later tool that has not opted in. A host gets one or the other. Two limits on the hiding half: it lapses once anything else has tainted the conversation, and it is about integrity only — a hidden item still contributes its confidentiality. Measured, with the full conditions, in [`docs/sandbox/information-flow.md`](https://github.com/sokolaidev/maf-extensions/blob/main/docs/sandbox/information-flow.md).
|
|
109
|
+
|
|
110
|
+
**`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.
|
|
111
|
+
|
|
112
|
+
**Withheld, the result is two items rather than one string, and hiding can reach one without the other.** The call-derived half — the exit code, the sizes, the landed names — carries no label of its own, so it takes whatever label the call would otherwise have had; beside it sits the sentence naming the recovery route, labelled `trusted`, because nothing a call produced reaches it and it is emitted on every return path, refusals included. **Where that call resolves untrusted and the conversation is still clean** — `default_integrity` is the host's to move, and hiding is a first-taint protection that stops once anything has tainted the conversation — a FIDES host with hiding on reads the sentence while the numbers sit behind a variable reference, which is the point of the split: under one label the sentence went with the numbers it was there to explain, and a model got a hidden result and no way to act on it. Where either condition fails both items are simply readable, which is the same outcome one string had. The call-derived half stays unlabelled deliberately: a per-item label replaces the *whole* label, confidentiality included, and confidentiality values are the host's vocabulary rather than this package's to invent. A host wiring no information-flow middleware simply reads both items.
|
|
109
113
|
|
|
110
114
|
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
115
|
|
|
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
|
|
116
|
+
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
117
|
|
|
114
118
|
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
119
|
|
|
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.
|
|
120
|
+
**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
121
|
|
|
120
122
|
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
123
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "maf-sandbox-codeact"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.11.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.32.0,<0.33",
|
|
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.11.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.32.0,<0.33",
|
|
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",
|
|
@@ -33,6 +33,7 @@ from dataclasses import dataclass, replace
|
|
|
33
33
|
from enum import StrEnum
|
|
34
34
|
from typing import TYPE_CHECKING, Any, cast
|
|
35
35
|
|
|
36
|
+
from agent_framework import Content
|
|
36
37
|
from maf_sandbox import (
|
|
37
38
|
DEFAULT_TRANSFER_LIMITS,
|
|
38
39
|
SHIM_MODULE,
|
|
@@ -43,6 +44,7 @@ from maf_sandbox import (
|
|
|
43
44
|
Egress,
|
|
44
45
|
ExecResult,
|
|
45
46
|
HostToolRun,
|
|
47
|
+
ListedFile,
|
|
46
48
|
NameNormalization,
|
|
47
49
|
OutputSink,
|
|
48
50
|
SandboxArtifactNameInvalid,
|
|
@@ -53,13 +55,20 @@ from maf_sandbox import (
|
|
|
53
55
|
SourceIntegrity,
|
|
54
56
|
TransferLimits,
|
|
55
57
|
collect_outputs,
|
|
58
|
+
echoed_name,
|
|
56
59
|
error_detail,
|
|
57
60
|
guest_run_layout,
|
|
58
61
|
host_tool_calls_over_exec,
|
|
59
62
|
host_tool_shim,
|
|
60
63
|
validate_artifact_name,
|
|
61
64
|
)
|
|
62
|
-
from maf_sandbox.maf import
|
|
65
|
+
from maf_sandbox.maf import (
|
|
66
|
+
SandboxToolSession,
|
|
67
|
+
hidden_content_candidates,
|
|
68
|
+
labelled_result_item,
|
|
69
|
+
positions_holding_hidden_content,
|
|
70
|
+
sandboxed_tool,
|
|
71
|
+
)
|
|
63
72
|
|
|
64
73
|
if TYPE_CHECKING:
|
|
65
74
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
@@ -82,10 +91,10 @@ EXECUTE_CODE_TOOL_NAME = "execute_code"
|
|
|
82
91
|
#: The sandbox kind this workload asks for.
|
|
83
92
|
CODEACT_KIND = "codeact"
|
|
84
93
|
|
|
85
|
-
#: Where every
|
|
94
|
+
#: Where every call's directory is created — a dedicated root rather than the image's own tree.
|
|
86
95
|
_WORK_DIR = "/maf-sandbox/work"
|
|
87
96
|
|
|
88
|
-
#: One fixed name inside each
|
|
97
|
+
#: One fixed name inside each call's own directory.
|
|
89
98
|
_PROGRAM_FILENAME = "program.py"
|
|
90
99
|
|
|
91
100
|
#: What this kind needs to reach for ``execute_code`` to work at all — nothing, because the
|
|
@@ -93,6 +102,11 @@ _PROGRAM_FILENAME = "program.py"
|
|
|
93
102
|
#: rather than an omission; :func:`codeact_sandbox_spec` has why it is fixed here.
|
|
94
103
|
_KIND_EGRESS: tuple[str, ...] = ()
|
|
95
104
|
|
|
105
|
+
#: The arguments a caller names files in, which is what a refusal about one of them points
|
|
106
|
+
#: at and what provenance is asked about.
|
|
107
|
+
_FILES_ARGUMENT = "files"
|
|
108
|
+
_OUTPUTS_ARGUMENT = "outputs"
|
|
109
|
+
|
|
96
110
|
#: Where a ``MANIFEST``-mode program says what it produced.
|
|
97
111
|
_MANIFEST_FILENAME = "outputs.json"
|
|
98
112
|
_MANIFEST_OUTPUTS_KEY = "outputs"
|
|
@@ -125,9 +139,11 @@ _NO_OUTPUT = (
|
|
|
125
139
|
"program with print(...) of what you need to see."
|
|
126
140
|
)
|
|
127
141
|
|
|
128
|
-
#: Closes every result a withholding host returns
|
|
129
|
-
#: the exit code alone leaves a model nothing to act on;
|
|
130
|
-
#: reader, which is the host's wiring rather than this
|
|
142
|
+
#: Closes every result a withholding host returns, as an item of its own labelled trusted. A
|
|
143
|
+
#: sentence rather than a silence, because the exit code alone leaves a model nothing to act on;
|
|
144
|
+
#: it names the route without promising a reader, which is the host's wiring rather than this
|
|
145
|
+
#: kind's to claim. The label holds only while nothing a call produced reaches it and it stays
|
|
146
|
+
#: on every return path, refusals included.
|
|
131
147
|
_WITHHELD_ROUTE = (
|
|
132
148
|
"What the program printed is not read back as text. To surface a value, write it into a "
|
|
133
149
|
"declared output rather than printing it."
|
|
@@ -250,11 +266,11 @@ def make_codeact_tools(
|
|
|
250
266
|
outputs: How a program's output files are named. See :class:`CodeactOutputs`.
|
|
251
267
|
withhold_guest_output: Keep what the program printed out of the tool result, and answer
|
|
252
268
|
with sizes and the model's own declared names instead. No guest-authored text
|
|
253
|
-
survives into the result
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
269
|
+
survives into the result — but the values that replace it were still chosen by a
|
|
270
|
+
program the model wrote, so this changes what the result *holds* and not where it
|
|
271
|
+
came from: the tool declares no ``source_integrity`` either way. Requires
|
|
272
|
+
:data:`CodeactOutputs.DECLARED`: the one mode where content can still reach the
|
|
273
|
+
model and no guest-chosen name reaches the result.
|
|
258
274
|
|
|
259
275
|
A size here is the UTF-8 length of the text the stream came back as, not the count
|
|
260
276
|
of bytes the program wrote: ``ExecResult`` states no decoding contract, so a
|
|
@@ -268,17 +284,28 @@ def make_codeact_tools(
|
|
|
268
284
|
``note:``, since withholding it would report a dropped output as a program that
|
|
269
285
|
printed nothing.
|
|
270
286
|
|
|
271
|
-
**
|
|
272
|
-
the
|
|
273
|
-
|
|
274
|
-
|
|
287
|
+
**The result is two items, not one string.** The call-derived half — the exit
|
|
288
|
+
code, the sizes, the landed names — carries no label of its own, so it takes
|
|
289
|
+
whatever the call's label is; beside it sits the standing sentence naming the recovery
|
|
290
|
+
route, labelled ``trusted``, because nothing a call produced reaches it and it is
|
|
291
|
+
emitted on every return path including the refusals. **Where that call resolves
|
|
292
|
+
untrusted and the conversation is still clean** — hiding is a first-taint
|
|
293
|
+
protection, and ``default_integrity`` is the host's to move — a framework hiding
|
|
294
|
+
untrusted content hides the first and leaves the second readable, which is the
|
|
295
|
+
point: under one label the sentence went with the numbers it was there to explain.
|
|
296
|
+
Neither condition is this kind's to promise; both are measured in
|
|
297
|
+
``docs/sandbox/information-flow.md``.
|
|
298
|
+
|
|
299
|
+
**What withholding gets you, exactly.** The prose and the shape are this package's,
|
|
300
|
+
and the artifact names are the model's own — but what fills them is the program's
|
|
301
|
+
to choose, and it is a channel rather than a leak-free boundary. The exit status is
|
|
302
|
+
8 bits; each stream's size is a few more, chosen by padding; and **each declared
|
|
275
303
|
output is one further bit**, since the program decides whether to write it and the
|
|
276
304
|
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`.
|
|
305
|
+
of them. So what stops crossing is guest-authored *text*, not every guest-chosen
|
|
306
|
+
*bit* — a narrow per-call channel rather than the open one a rendered ``stdout`` is,
|
|
307
|
+
and a host that must close it should not attach this workload at all. The sink's
|
|
308
|
+
``display`` is deliberately *not* rendered here — see :func:`_format_landed`.
|
|
282
309
|
outbound_max_confidentiality: The host's cap for tools that carry something out, in the
|
|
283
310
|
host's own vocabulary. Off by default and written only when something can actually
|
|
284
311
|
leave: an artifact landing in the sink, a host tool that carries something out, or
|
|
@@ -457,31 +484,18 @@ def make_codeact_tools(
|
|
|
457
484
|
name=EXECUTE_CODE_TOOL_NAME,
|
|
458
485
|
approval_mode="always_require" if approval_gated else "never_require",
|
|
459
486
|
also_carries_out=registry_carries_out,
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
#
|
|
463
|
-
#
|
|
464
|
-
#
|
|
465
|
-
source_integrity=
|
|
487
|
+
# Never declared, withheld or not. What comes back is whatever a model-written
|
|
488
|
+
# `print(...)` chose to emit; withheld it is an exit status, two sizes and a presence
|
|
489
|
+
# bit per declared output, every one of them chosen by a program the model wrote. A
|
|
490
|
+
# declaration replaces the framework's input-label join rather than flooring it, so
|
|
491
|
+
# declaring anything here would tell a host's middleware to disregard the input side.
|
|
492
|
+
source_integrity=None,
|
|
466
493
|
outbound_max_confidentiality=outbound_max_confidentiality,
|
|
467
494
|
output_sink=output_sink,
|
|
468
495
|
logger=logger,
|
|
469
496
|
)
|
|
470
497
|
|
|
471
498
|
|
|
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
499
|
def _effective_egress(extra: Sequence[str]) -> tuple[str, ...]:
|
|
486
500
|
"""The union of what this kind needs and what the deployment added, in that order.
|
|
487
501
|
|
|
@@ -613,6 +627,10 @@ _DESCRIPTION_HEAD = """Run a short Python program inside a sandbox and return wh
|
|
|
613
627
|
#: reads, so a withheld tool built on it argues with its own `Returns:` section. This one stays
|
|
614
628
|
#: transport-neutral about the shape and says "how large" rather than a count of bytes written:
|
|
615
629
|
#: `Returns:` is where one merged size is told from two, and neither is what the program wrote.
|
|
630
|
+
#: The claim sits on the streams rather than on `print`, because the withholding does: `logging`
|
|
631
|
+
#: defaults to stderr and a traceback goes there too, so a model told about `print` alone reads
|
|
632
|
+
#: those as a channel that comes back. And it offers the streams no use at all — naming one,
|
|
633
|
+
#: debugging included, licenses the writing this mode exists to redirect.
|
|
616
634
|
_DESCRIPTION_HEAD_WITHHELD = """Run a short Python program inside a sandbox and report what it
|
|
617
635
|
did.
|
|
618
636
|
|
|
@@ -620,9 +638,13 @@ _DESCRIPTION_HEAD_WITHHELD = """Run a short Python program inside a sandbox and
|
|
|
620
638
|
anything where running the code beats predicting what it would do. The program runs
|
|
621
639
|
as ``python3 program.py`` in a sandbox with {network}
|
|
622
640
|
|
|
623
|
-
**
|
|
624
|
-
|
|
625
|
-
|
|
641
|
+
**Nothing your program writes to stdout or stderr comes back — not as a value, not to
|
|
642
|
+
debug with.** You get the exit code and how large the output was, never what was in it,
|
|
643
|
+
so nothing you write there can show you anything, even when the program fails. That
|
|
644
|
+
covers every route to those streams: ``print``, writes to ``sys.stdout`` or
|
|
645
|
+
``sys.stderr``, the ``logging`` and ``warnings`` modules, tracebacks, and whatever a
|
|
646
|
+
subprocess you start prints. Write everything you need to see — results and diagnostics
|
|
647
|
+
alike — into a declared output instead.
|
|
626
648
|
|
|
627
649
|
Write a complete, self-contained program every time. Each call gets a fresh working
|
|
628
650
|
directory: nothing you did not pass in to *this* call is in it."""
|
|
@@ -825,15 +847,17 @@ def _execute_code_tool(
|
|
|
825
847
|
host_tool_call: _HostToolCall | None,
|
|
826
848
|
*,
|
|
827
849
|
withhold: bool,
|
|
828
|
-
) -> Callable[..., Awaitable[str]]:
|
|
850
|
+
) -> Callable[..., Awaitable[str | list[Content]]]:
|
|
829
851
|
"""Build the ``execute_code`` body for one attached tool.
|
|
830
852
|
|
|
831
853
|
Four signatures over one implementation, because MAF derives the tool's schema from the
|
|
832
854
|
function's parameters: a host that wired no file store must not be shown ``files``.
|
|
833
855
|
"""
|
|
834
856
|
|
|
835
|
-
async def run(
|
|
836
|
-
|
|
857
|
+
async def run(
|
|
858
|
+
code: str, files: list[str] | None, declared: list[str] | None
|
|
859
|
+
) -> str | list[Content]:
|
|
860
|
+
answer = await _execute(
|
|
837
861
|
session,
|
|
838
862
|
store,
|
|
839
863
|
outputs,
|
|
@@ -844,19 +868,27 @@ def _execute_code_tool(
|
|
|
844
868
|
declared or [],
|
|
845
869
|
withhold=withhold,
|
|
846
870
|
)
|
|
871
|
+
if not withhold:
|
|
872
|
+
return answer
|
|
873
|
+
# At the funnel rather than at each `return` inside `_execute`: the trusted label is
|
|
874
|
+
# honest only where the sentence is on every path, refusals included.
|
|
875
|
+
return [
|
|
876
|
+
Content.from_text(answer),
|
|
877
|
+
labelled_result_item(_WITHHELD_ROUTE, SourceIntegrity.TRUSTED),
|
|
878
|
+
]
|
|
847
879
|
|
|
848
880
|
async def with_files_and_outputs(
|
|
849
881
|
code: str, files: list[str] | None = None, outputs: list[str] | None = None
|
|
850
|
-
) -> str:
|
|
882
|
+
) -> str | list[Content]:
|
|
851
883
|
return await run(code, files, outputs)
|
|
852
884
|
|
|
853
|
-
async def with_files(code: str, files: list[str] | None = None) -> str:
|
|
885
|
+
async def with_files(code: str, files: list[str] | None = None) -> str | list[Content]:
|
|
854
886
|
return await run(code, files, None)
|
|
855
887
|
|
|
856
|
-
async def with_outputs(code: str, outputs: list[str] | None = None) -> str:
|
|
888
|
+
async def with_outputs(code: str, outputs: list[str] | None = None) -> str | list[Content]:
|
|
857
889
|
return await run(code, None, outputs)
|
|
858
890
|
|
|
859
|
-
async def plain(code: str) -> str:
|
|
891
|
+
async def plain(code: str) -> str | list[Content]:
|
|
860
892
|
return await run(code, None, None)
|
|
861
893
|
|
|
862
894
|
takes_files = store is not None
|
|
@@ -898,6 +930,10 @@ async def _execute(
|
|
|
898
930
|
withhold: bool,
|
|
899
931
|
) -> str:
|
|
900
932
|
"""One ``execute_code`` call: share, run, and collect."""
|
|
933
|
+
# Taken before anything here awaits, and carried to every name check in this call: the
|
|
934
|
+
# framework's accessor is not scoped to the call, so a lookup made after the run — the
|
|
935
|
+
# manifest's, above all — may find nothing left to answer with.
|
|
936
|
+
rewritten = hidden_content_candidates()
|
|
901
937
|
# Scope and thread come from the host's request context, never from model input.
|
|
902
938
|
key = session.key()
|
|
903
939
|
if isinstance(key, str):
|
|
@@ -922,16 +958,17 @@ async def _execute(
|
|
|
922
958
|
)
|
|
923
959
|
|
|
924
960
|
# 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
|
|
961
|
+
# the guest path it will actually become — the prefix is the id core allocates for the
|
|
962
|
+
# call, derived below rather than counted.
|
|
926
963
|
call_directory = session.guest_call_path()
|
|
927
|
-
|
|
928
|
-
# Where the model's own files live, relative to `work_dir`: the
|
|
964
|
+
call_id = call_directory.rsplit("/", 1)[-1]
|
|
965
|
+
# Where the model's own files live, relative to `work_dir`: the call directory itself, or
|
|
929
966
|
# the work subdirectory of it when the transport owns the run. Everything addressed by a
|
|
930
967
|
# 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
|
|
968
|
+
# from, and what is collected out — so the three cannot disagree about one call's layout.
|
|
932
969
|
# It is longer when calling a host tool, which is why the name checks below take it rather than
|
|
933
|
-
# `
|
|
934
|
-
guest_prefix = f"{
|
|
970
|
+
# `call_id`: five more bytes of the 255 a guest path gets, spent before the name is.
|
|
971
|
+
guest_prefix = f"{call_id}/{WORK_DIRECTORY}" if host_tool_call is not None else call_id
|
|
935
972
|
|
|
936
973
|
names: list[str] = []
|
|
937
974
|
if outputs is CodeactOutputs.DECLARED:
|
|
@@ -941,6 +978,9 @@ async def _execute(
|
|
|
941
978
|
reserved=reserved,
|
|
942
979
|
guest_prefix=guest_prefix,
|
|
943
980
|
normalization=_normalization(session),
|
|
981
|
+
named_by=_OUTPUTS_ARGUMENT,
|
|
982
|
+
argument=_OUTPUTS_ARGUMENT,
|
|
983
|
+
candidates=rewritten,
|
|
944
984
|
)
|
|
945
985
|
if isinstance(checked, str):
|
|
946
986
|
return checked
|
|
@@ -956,7 +996,7 @@ async def _execute(
|
|
|
956
996
|
# host-tool call under the registry's `response_limits`.
|
|
957
997
|
limits = session.spec.files_in
|
|
958
998
|
tally = _InboundTally(limits)
|
|
959
|
-
shared: list[tuple[str, str]] = []
|
|
999
|
+
shared: list[tuple[str, str, str]] = []
|
|
960
1000
|
inbound = len(files) + (2 if host_tool_call is not None else 1)
|
|
961
1001
|
over_cap = _over_file_count(
|
|
962
1002
|
inbound, limits, calls_host_tool=host_tool_call is not None
|
|
@@ -966,12 +1006,13 @@ async def _execute(
|
|
|
966
1006
|
if over_cap is not None:
|
|
967
1007
|
return over_cap
|
|
968
1008
|
if store is not None:
|
|
969
|
-
|
|
970
|
-
session, store, files, reserved=reserved, withhold=withhold
|
|
1009
|
+
resolution = await _resolve_listed_files(
|
|
1010
|
+
session, store, files, reserved=reserved, withhold=withhold, candidates=rewritten
|
|
971
1011
|
)
|
|
972
|
-
if isinstance(
|
|
973
|
-
return
|
|
974
|
-
|
|
1012
|
+
if isinstance(resolution, str):
|
|
1013
|
+
return resolution
|
|
1014
|
+
resolved, resolved_hidden = resolution
|
|
1015
|
+
read = await _read_listed_files(session, store, resolved, tally, rewritten=resolved_hidden)
|
|
975
1016
|
if isinstance(read, str):
|
|
976
1017
|
return read
|
|
977
1018
|
shared = read
|
|
@@ -981,9 +1022,9 @@ async def _execute(
|
|
|
981
1022
|
return sandbox
|
|
982
1023
|
|
|
983
1024
|
# 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
|
|
1025
|
+
# Built before anything is written, because it decides where everything goes. A call that
|
|
985
1026
|
# 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
|
|
1027
|
+
# in the transport's — and one that does not is the call directory flat, which is what a kind
|
|
987
1028
|
# writing no shim has always been. The program name and the interpreter are passed rather
|
|
988
1029
|
# than defaulted, so this kind's constants and the transport's cannot drift apart.
|
|
989
1030
|
layout = (
|
|
@@ -993,9 +1034,9 @@ async def _execute(
|
|
|
993
1034
|
)
|
|
994
1035
|
shared_dir = layout.work if layout is not None else call_directory
|
|
995
1036
|
|
|
996
|
-
for name, content in shared:
|
|
1037
|
+
for name, named, content in shared:
|
|
997
1038
|
refusal = await _write_shared(
|
|
998
|
-
sandbox, name, f"{shared_dir}/{name}", content, working_directory=shared_dir
|
|
1039
|
+
sandbox, name, named, f"{shared_dir}/{name}", content, working_directory=shared_dir
|
|
999
1040
|
)
|
|
1000
1041
|
if refusal is not None:
|
|
1001
1042
|
return refusal
|
|
@@ -1037,9 +1078,7 @@ async def _execute(
|
|
|
1037
1078
|
# The transport's own bound — but *which* of its bounds is something only its message
|
|
1038
1079
|
# knows. The run can expire before the program is started at all, and that message
|
|
1039
1080
|
# says so where a reconstructed "the program timed out" would blame code that never
|
|
1040
|
-
# ran.
|
|
1041
|
-
# the host's reason for having none; `output` is empty in the second case, so
|
|
1042
|
-
# rebuilding the sentence from that attribute drops the reason silently.
|
|
1081
|
+
# ran.
|
|
1043
1082
|
#
|
|
1044
1083
|
# It says what was *attempted* on the program too — its process group signalled, the
|
|
1045
1084
|
# program signalled alone, or nothing signalled. None of the three is a claim that the
|
|
@@ -1051,24 +1090,28 @@ async def _execute(
|
|
|
1051
1090
|
# a backend's own text kept to the log, which is the same rule this kind follows.
|
|
1052
1091
|
logger.warning("execute_code: %s", expired)
|
|
1053
1092
|
if withhold:
|
|
1054
|
-
# The
|
|
1055
|
-
#
|
|
1056
|
-
#
|
|
1057
|
-
#
|
|
1058
|
-
#
|
|
1059
|
-
#
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1093
|
+
# The message quotes the program, so the sentence is rebuilt from the attributes
|
|
1094
|
+
# instead — and it names no bound, because *whose* expired is not knowable here. A
|
|
1095
|
+
# backend may raise this public type from a call of its own, the transport
|
|
1096
|
+
# propagates that untranslated, and the subtype that tells the two apart is core's
|
|
1097
|
+
# private one. `signal` is the discriminator the exception does carry, and
|
|
1098
|
+
# `"absent"` is its one value asserting nothing started.
|
|
1099
|
+
what_happened = (
|
|
1100
|
+
"the time ran out before the program was started"
|
|
1101
|
+
if expired.signal == "absent"
|
|
1102
|
+
else "the program did not finish in the time it was given"
|
|
1103
|
+
)
|
|
1104
|
+
# The host's own words for why it read no output, surfaced whole like the note on
|
|
1105
|
+
# the success path: it is the half of the message the guest did not write.
|
|
1106
|
+
reason = f" {expired.output_reason}." if expired.output_reason else ""
|
|
1107
|
+
return f"Error: {what_happened}.{reason}"
|
|
1063
1108
|
return f"Error: {expired}"
|
|
1064
1109
|
except TimeoutError as unfinished:
|
|
1065
1110
|
if host_tool_call is None:
|
|
1066
1111
|
# One `exec`, one bound: a timeout here is that bound and nothing else, so unlike
|
|
1067
|
-
# the branch above this one may name it.
|
|
1068
|
-
# every shipped backend reaches this line rather than that one.
|
|
1112
|
+
# the branch above this one may name it.
|
|
1069
1113
|
logger.warning("execute_code: the program timed out after %ss", timeout)
|
|
1070
|
-
|
|
1071
|
-
return f"{expiry}. {_WITHHELD_ROUTE}" if withhold else expiry
|
|
1114
|
+
return f"Error: the program timed out after {timeout}s"
|
|
1072
1115
|
# A backend bounding one of its own control-plane calls, which the transport re-raises
|
|
1073
1116
|
# untranslated. Blaming the program would be a guess about code the model is about to
|
|
1074
1117
|
# rewrite — and the wrong one, since the run may have had most of its time left.
|
|
@@ -1080,11 +1123,7 @@ async def _execute(
|
|
|
1080
1123
|
return "Error: could not run the program in the sandbox"
|
|
1081
1124
|
|
|
1082
1125
|
logger.info("execute_code: ran exit_code=%d shared=%d", result.exit_code, len(shared))
|
|
1083
|
-
report = (
|
|
1084
|
-
_format_withheld(result, over_transport=host_tool_call is not None)
|
|
1085
|
-
if withhold
|
|
1086
|
-
else _format_result(result)
|
|
1087
|
-
)
|
|
1126
|
+
report = _format_withheld(result) if withhold else _format_result(result)
|
|
1088
1127
|
nothing_to_collect = outputs is CodeactOutputs.NONE or (
|
|
1089
1128
|
outputs is CodeactOutputs.DECLARED and not names
|
|
1090
1129
|
)
|
|
@@ -1095,7 +1134,14 @@ async def _execute(
|
|
|
1095
1134
|
# a program that caught its own error and wrote the diagnosis into one.
|
|
1096
1135
|
return report
|
|
1097
1136
|
collected = await _collect(
|
|
1098
|
-
session,
|
|
1137
|
+
session,
|
|
1138
|
+
sandbox,
|
|
1139
|
+
guest_prefix,
|
|
1140
|
+
outputs,
|
|
1141
|
+
names,
|
|
1142
|
+
reserved,
|
|
1143
|
+
withhold=withhold,
|
|
1144
|
+
candidates=rewritten,
|
|
1099
1145
|
)
|
|
1100
1146
|
return f"{report}\n\n{collected}" if collected else report
|
|
1101
1147
|
|
|
@@ -1110,15 +1156,29 @@ async def _resolve_listed_files(
|
|
|
1110
1156
|
*,
|
|
1111
1157
|
reserved: Mapping[str, str],
|
|
1112
1158
|
withhold: bool = False,
|
|
1113
|
-
|
|
1159
|
+
candidates: frozenset[str] | None = None,
|
|
1160
|
+
) -> tuple[list[tuple[ListedFile, str]], frozenset[int]] | str:
|
|
1114
1161
|
"""Match each requested name against the caller's listing, or answer with the refusal.
|
|
1115
1162
|
|
|
1116
1163
|
The listing is the injection-pinning boundary: a name the model invented, or read out of a
|
|
1117
1164
|
poisoned file, has nowhere to go. Which is why a listing that cannot be read is a refusal
|
|
1118
1165
|
rather than an empty one — every name would then be refused for the wrong reason.
|
|
1166
|
+
|
|
1167
|
+
Answers with each entry **and the spelling a refusal about it may use**, plus the positions
|
|
1168
|
+
among them the framework expanded. The rendering is made here because this is the only place
|
|
1169
|
+
that holds both spellings: the model's, and the listing key it resolved to. Rendering later
|
|
1170
|
+
from :attr:`ListedFile.name` would report the length of the host's key while claiming to
|
|
1171
|
+
stand in for the value at the model's position, and the two differ whenever the listing
|
|
1172
|
+
normalised anything.
|
|
1119
1173
|
"""
|
|
1120
1174
|
if not files:
|
|
1121
|
-
return []
|
|
1175
|
+
return [], frozenset()
|
|
1176
|
+
# Asked before the first await, not beside the loop that uses it: the framework's accessor
|
|
1177
|
+
# is not scoped to the call, so every suspension before asking is a chance for the answer
|
|
1178
|
+
# to come back empty. See `positions_holding_hidden_content`.
|
|
1179
|
+
rewritten = positions_holding_hidden_content(
|
|
1180
|
+
files, argument=_FILES_ARGUMENT, candidates=candidates
|
|
1181
|
+
)
|
|
1122
1182
|
listing = await session.list_files(store)
|
|
1123
1183
|
if isinstance(listing, str):
|
|
1124
1184
|
# The host's own sentence about its store. Withheld it is dropped for the reason the
|
|
@@ -1127,26 +1187,32 @@ async def _resolve_listed_files(
|
|
|
1127
1187
|
logger.warning("execute_code: the file listing could not be read: %s", listing)
|
|
1128
1188
|
return "Error: this tool's file listing could not be read, so nothing was shared."
|
|
1129
1189
|
return listing
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1190
|
+
listed_by_name = {entry.name: entry for entry in listing}
|
|
1191
|
+
known = set(listed_by_name)
|
|
1192
|
+
resolved: list[tuple[ListedFile, str]] = []
|
|
1193
|
+
resolved_hidden: set[int] = set()
|
|
1194
|
+
seen: set[str] = set()
|
|
1195
|
+
for position, name in enumerate(files):
|
|
1196
|
+
at = f"files[{position}]"
|
|
1197
|
+
hidden = position in rewritten
|
|
1198
|
+
named = echoed_name(name, at=at, hidden=hidden)
|
|
1133
1199
|
try:
|
|
1134
|
-
validate_artifact_name(name)
|
|
1200
|
+
validate_artifact_name(name, at=at, hidden=hidden)
|
|
1135
1201
|
except SandboxArtifactNameInvalid as exc:
|
|
1136
1202
|
# The validator's own sentence, which names the rule that was broken: a fixed
|
|
1137
1203
|
# message listing two of its rules tells a caller refused for a backslash or a
|
|
1138
1204
|
# control character that its name satisfies everything the tool asked for. The
|
|
1139
1205
|
# listing is still not echoed — that would invite a retry with another spelling.
|
|
1140
|
-
return f"Error: {
|
|
1206
|
+
return f"Error: {named} cannot be shared — {exc}"
|
|
1141
1207
|
if name in reserved:
|
|
1142
|
-
return f"Error: {
|
|
1143
|
-
refusal = _inside_a_reserved_file(name, reserved, action="shared")
|
|
1208
|
+
return f"Error: {named} cannot be shared — {reserved[name]}."
|
|
1209
|
+
refusal = _inside_a_reserved_file(name, reserved, action="shared", at=at, hidden=hidden)
|
|
1144
1210
|
if refusal is not None:
|
|
1145
1211
|
return refusal
|
|
1146
|
-
if name in
|
|
1212
|
+
if name in seen:
|
|
1147
1213
|
# One read and one write per name. Repeating one buys the caller nothing and
|
|
1148
1214
|
# multiplies both, which is the cheapest way to amplify against the byte ceilings.
|
|
1149
|
-
return f"Error: {
|
|
1215
|
+
return f"Error: {named} was listed twice."
|
|
1150
1216
|
if name not in known:
|
|
1151
1217
|
logger.warning(
|
|
1152
1218
|
"execute_code: %r is not in this tool's file store listing (%d file(s) visible) "
|
|
@@ -1155,11 +1221,14 @@ async def _resolve_listed_files(
|
|
|
1155
1221
|
len(listing),
|
|
1156
1222
|
)
|
|
1157
1223
|
return (
|
|
1158
|
-
f"Error: {
|
|
1159
|
-
f"{_listing_hint(name,
|
|
1224
|
+
f"Error: {named} is not in this tool's file listing, so it was not shared. "
|
|
1225
|
+
f"{_listing_hint(name, sorted(known), withhold=withhold)}"
|
|
1160
1226
|
)
|
|
1161
|
-
|
|
1162
|
-
|
|
1227
|
+
if hidden:
|
|
1228
|
+
resolved_hidden.add(len(resolved))
|
|
1229
|
+
resolved.append((listed_by_name[name], named))
|
|
1230
|
+
seen.add(name)
|
|
1231
|
+
return resolved, frozenset(resolved_hidden)
|
|
1163
1232
|
|
|
1164
1233
|
|
|
1165
1234
|
#: Capped so a large file store cannot flood the model's context.
|
|
@@ -1172,8 +1241,8 @@ def _listing_hint(name: str, listing: list[str], *, withhold: bool = False) -> s
|
|
|
1172
1241
|
``withhold`` names none of them. A store's filenames are the host's to supply through
|
|
1173
1242
|
``list_files`` and carry no integrity contract of their own — an agent that saved something
|
|
1174
1243
|
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
|
-
|
|
1244
|
+
:data:`_LISTING_HINT_MAX` of them would put unclassified text into a result whose whole
|
|
1245
|
+
point is to hold none, for a name the model never asked about.
|
|
1177
1246
|
"""
|
|
1178
1247
|
if not listing:
|
|
1179
1248
|
return "This tool's listing is empty — no files were shared with it."
|
|
@@ -1188,40 +1257,55 @@ def _listing_hint(name: str, listing: list[str], *, withhold: bool = False) -> s
|
|
|
1188
1257
|
|
|
1189
1258
|
|
|
1190
1259
|
async def _read_listed_files(
|
|
1191
|
-
|
|
1192
|
-
|
|
1260
|
+
session: SandboxToolSession,
|
|
1261
|
+
store: AgentFileStore,
|
|
1262
|
+
files: list[tuple[ListedFile, str]],
|
|
1263
|
+
tally: _InboundTally,
|
|
1264
|
+
*,
|
|
1265
|
+
rewritten: frozenset[int] = frozenset(),
|
|
1266
|
+
) -> list[tuple[str, str, str]] | str:
|
|
1193
1267
|
"""Read every requested file into memory, or answer with the refusal.
|
|
1194
1268
|
|
|
1195
1269
|
Each file is counted **as it arrives**, so a breach stops the next read rather than the
|
|
1196
1270
|
write: a tally applied to the finished set bounds what crosses into the sandbox and nothing
|
|
1197
1271
|
about what this process spent getting there.
|
|
1198
1272
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1273
|
+
Read through the session rather than the store, so each file arrives as a labelled item
|
|
1274
|
+
saying what the host knows about its bytes (:class:`~maf_sandbox.ListedFile`).
|
|
1275
|
+
|
|
1276
|
+
``rewritten`` carries the argument positions the framework expanded, and every refusal below
|
|
1277
|
+
renders those by position: a name that came out of hidden content is the one value a refusal
|
|
1278
|
+
must not repeat. Each file is answered with that rendering beside its real name, because the
|
|
1279
|
+
write that follows this read is where the verdict would otherwise be lost — matching the
|
|
1280
|
+
listing is not what makes a name safe to echo.
|
|
1203
1281
|
"""
|
|
1204
|
-
read: list[tuple[str, str]] = []
|
|
1205
|
-
for
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
return
|
|
1211
|
-
if
|
|
1282
|
+
read: list[tuple[str, str, str]] = []
|
|
1283
|
+
for position, (listed, named) in enumerate(files):
|
|
1284
|
+
at = f"files[{position}]"
|
|
1285
|
+
hidden = position in rewritten
|
|
1286
|
+
item = await session.read_file(store, listed, at=at, hidden=hidden, named=named)
|
|
1287
|
+
if isinstance(item, str):
|
|
1288
|
+
return item
|
|
1289
|
+
if item is None:
|
|
1212
1290
|
# A store read can miss without raising (the file was listed, then removed). Writing
|
|
1213
1291
|
# `None` through would put the string "None" into the sandbox for the program to
|
|
1214
1292
|
# parse.
|
|
1215
|
-
logger.warning("execute_code:
|
|
1216
|
-
return f"Error: {
|
|
1217
|
-
|
|
1293
|
+
logger.warning("execute_code: a listed file has no content")
|
|
1294
|
+
return f"Error: {named} is listed in the file store but has no content"
|
|
1295
|
+
content = item.text
|
|
1296
|
+
if content is None:
|
|
1297
|
+
logger.warning("execute_code: a listed file read back with no text")
|
|
1298
|
+
return f"Error: {named} is listed in the file store but has no content"
|
|
1299
|
+
over_cap = tally.add(listed.name, content, named=named)
|
|
1218
1300
|
if over_cap is not None:
|
|
1219
1301
|
return over_cap
|
|
1220
|
-
read.append((name, content))
|
|
1302
|
+
read.append((listed.name, named, content))
|
|
1221
1303
|
return read
|
|
1222
1304
|
|
|
1223
1305
|
|
|
1224
|
-
def _inside_a_reserved_file(
|
|
1306
|
+
def _inside_a_reserved_file(
|
|
1307
|
+
name: str, reserved: Mapping[str, str], *, action: str, at: str, hidden: bool
|
|
1308
|
+
) -> str | None:
|
|
1225
1309
|
"""Refuse a name that would have to live inside a reserved file, naming which one.
|
|
1226
1310
|
|
|
1227
1311
|
Every shipped backend creates parent directories for a nested write, so
|
|
@@ -1235,8 +1319,8 @@ def _inside_a_reserved_file(name: str, reserved: Mapping[str, str], *, action: s
|
|
|
1235
1319
|
if above is None:
|
|
1236
1320
|
return None
|
|
1237
1321
|
return (
|
|
1238
|
-
f"Error: {name
|
|
1239
|
-
f"every run's directory, so nothing can live inside it."
|
|
1322
|
+
f"Error: {echoed_name(name, at=at, hidden=hidden)} cannot be {action} — {above!r} is a "
|
|
1323
|
+
f"file name this tool reserves in every run's directory, so nothing can live inside it."
|
|
1240
1324
|
)
|
|
1241
1325
|
|
|
1242
1326
|
|
|
@@ -1272,8 +1356,15 @@ class _InboundTally:
|
|
|
1272
1356
|
self._limits = limits
|
|
1273
1357
|
self._total = 0
|
|
1274
1358
|
|
|
1275
|
-
def add(self, name: str, content: str) -> str | None:
|
|
1276
|
-
"""Count one file, or answer with the refusal that should stop the next read.
|
|
1359
|
+
def add(self, name: str, content: str, *, named: str | None = None) -> str | None:
|
|
1360
|
+
"""Count one file, or answer with the refusal that should stop the next read.
|
|
1361
|
+
|
|
1362
|
+
``named`` is how the file may be spelled in a refusal, and defaults to ``repr(name)`` —
|
|
1363
|
+
which is what a caller naming a constant wants. A caller counting a file the model
|
|
1364
|
+
named passes the position-safe rendering instead: these two refusals reach the model,
|
|
1365
|
+
and a name the framework expanded out of hidden content is not the caller's to echo.
|
|
1366
|
+
"""
|
|
1367
|
+
shown = named if named is not None else repr(name)
|
|
1277
1368
|
try:
|
|
1278
1369
|
size = len(content.encode())
|
|
1279
1370
|
except UnicodeEncodeError:
|
|
@@ -1281,12 +1372,12 @@ class _InboundTally:
|
|
|
1281
1372
|
# encoded. This tally runs outside the guarded write, so without this the turn
|
|
1282
1373
|
# dies here rather than the model being told what to fix.
|
|
1283
1374
|
return (
|
|
1284
|
-
f"Error: {
|
|
1375
|
+
f"Error: {shown} is not valid UTF-8 and cannot be written into the sandbox. "
|
|
1285
1376
|
f"Nothing was shared."
|
|
1286
1377
|
)
|
|
1287
1378
|
if size > self._limits.max_bytes_per_file:
|
|
1288
1379
|
return (
|
|
1289
|
-
f"Error: {
|
|
1380
|
+
f"Error: {shown} is {size} bytes and this tool writes at most "
|
|
1290
1381
|
f"{self._limits.max_bytes_per_file} bytes per file. Nothing was shared."
|
|
1291
1382
|
)
|
|
1292
1383
|
self._total += size
|
|
@@ -1302,19 +1393,25 @@ class _InboundTally:
|
|
|
1302
1393
|
async def _write_shared(
|
|
1303
1394
|
sandbox: Sandbox,
|
|
1304
1395
|
name: str,
|
|
1396
|
+
named: str,
|
|
1305
1397
|
guest_path: str,
|
|
1306
1398
|
content: str,
|
|
1307
1399
|
*,
|
|
1308
1400
|
working_directory: str,
|
|
1309
1401
|
) -> str | None:
|
|
1310
|
-
"""Put one already-read file store file into the run's directory, or answer with the refusal.
|
|
1402
|
+
"""Put one already-read file store file into the run's directory, or answer with the refusal.
|
|
1403
|
+
|
|
1404
|
+
``name`` is the real store path — the guest path is built from it and the host's log records
|
|
1405
|
+
it — while ``named`` is the only spelling that may appear in the refusal. They differ where
|
|
1406
|
+
the framework expanded hidden content into the argument this file was named by.
|
|
1407
|
+
"""
|
|
1311
1408
|
try:
|
|
1312
1409
|
await sandbox.write_file(guest_path, content, working_directory=working_directory)
|
|
1313
1410
|
except Exception as exc: # noqa: BLE001
|
|
1314
1411
|
logger.warning(
|
|
1315
1412
|
"execute_code: could not write %r into the sandbox: %s", name, error_detail(exc)
|
|
1316
1413
|
)
|
|
1317
|
-
return f"Error: could not share {
|
|
1414
|
+
return f"Error: could not share {named} into the sandbox"
|
|
1318
1415
|
return None
|
|
1319
1416
|
|
|
1320
1417
|
|
|
@@ -1334,6 +1431,9 @@ def _validated_output_names(
|
|
|
1334
1431
|
reserved: Mapping[str, str],
|
|
1335
1432
|
guest_prefix: str,
|
|
1336
1433
|
normalization: NameNormalization,
|
|
1434
|
+
named_by: str,
|
|
1435
|
+
argument: str | None = None,
|
|
1436
|
+
candidates: frozenset[str] | None = None,
|
|
1337
1437
|
) -> list[str] | str:
|
|
1338
1438
|
"""Settle every output name before the program runs, or answer with the refusal.
|
|
1339
1439
|
|
|
@@ -1341,6 +1441,9 @@ def _validated_output_names(
|
|
|
1341
1441
|
with its run prefix, and the delivered name after normalization — so that a refusal cannot
|
|
1342
1442
|
arrive a whole run late. That function stays the authority: if the two disagree, this one
|
|
1343
1443
|
is wrong, and the cost is the late refusal rather than a name reaching a host.
|
|
1444
|
+
|
|
1445
|
+
``named_by`` says where the names came from — the ``outputs`` argument or the manifest —
|
|
1446
|
+
so a refusal can point at the one it means without quoting a value it should not.
|
|
1344
1447
|
"""
|
|
1345
1448
|
if len(names) > max_files:
|
|
1346
1449
|
return (
|
|
@@ -1348,8 +1451,15 @@ def _validated_output_names(
|
|
|
1348
1451
|
f"{max_files} per call."
|
|
1349
1452
|
)
|
|
1350
1453
|
prefix = f"{guest_prefix}/"
|
|
1351
|
-
seen: dict[str, str] = {}
|
|
1352
|
-
|
|
1454
|
+
seen: dict[str, tuple[str, str]] = {}
|
|
1455
|
+
# Asked of manifest names as well as of the model's own `outputs`, and that is not
|
|
1456
|
+
# belt-and-braces: `code` is a rewritten argument too, so a payload can reach the guest
|
|
1457
|
+
# in the program's own source and come back as a name the program chose to write.
|
|
1458
|
+
rewritten = positions_holding_hidden_content(names, argument=argument, candidates=candidates)
|
|
1459
|
+
for position, name in enumerate(names):
|
|
1460
|
+
at = f"{named_by}[{position}]"
|
|
1461
|
+
hidden = position in rewritten
|
|
1462
|
+
named = echoed_name(name, at=at, hidden=hidden)
|
|
1353
1463
|
# NFC is not length-non-increasing — 43 × U+0958 is 129 bytes declared and 258
|
|
1354
1464
|
# delivered — so the name to hold to the invariant is the one the sink will receive.
|
|
1355
1465
|
delivered = (
|
|
@@ -1357,25 +1467,25 @@ def _validated_output_names(
|
|
|
1357
1467
|
)
|
|
1358
1468
|
for spelling in (name, delivered, prefix + name):
|
|
1359
1469
|
try:
|
|
1360
|
-
validate_artifact_name(spelling)
|
|
1470
|
+
validate_artifact_name(spelling, at=at, hidden=hidden)
|
|
1361
1471
|
except SandboxArtifactNameInvalid as exc:
|
|
1362
|
-
return f"Error: {
|
|
1472
|
+
return f"Error: {named} cannot be saved — {exc}"
|
|
1363
1473
|
if name in reserved:
|
|
1364
|
-
return f"Error: {
|
|
1365
|
-
refusal = _inside_a_reserved_file(name, reserved, action="saved")
|
|
1474
|
+
return f"Error: {named} cannot be saved — {reserved[name]}."
|
|
1475
|
+
refusal = _inside_a_reserved_file(name, reserved, action="saved", at=at, hidden=hidden)
|
|
1366
1476
|
if refusal is not None:
|
|
1367
1477
|
return refusal
|
|
1368
1478
|
# `collect_outputs`' own key: NFC and case-folded, always, whatever the sink does
|
|
1369
1479
|
# about rewriting.
|
|
1370
1480
|
key = unicodedata.normalize("NFC", name).lower()
|
|
1371
1481
|
if key in seen:
|
|
1372
|
-
earlier = seen[key]
|
|
1482
|
+
earlier, earlier_named = seen[key]
|
|
1373
1483
|
return (
|
|
1374
|
-
f"Error: {
|
|
1484
|
+
f"Error: {named} and {earlier_named} are one file once saved"
|
|
1375
1485
|
if earlier != name
|
|
1376
|
-
else f"Error: {
|
|
1486
|
+
else f"Error: {named} was declared twice."
|
|
1377
1487
|
)
|
|
1378
|
-
seen[key] = name
|
|
1488
|
+
seen[key] = (name, named)
|
|
1379
1489
|
return list(names)
|
|
1380
1490
|
|
|
1381
1491
|
|
|
@@ -1388,6 +1498,7 @@ async def _collect(
|
|
|
1388
1498
|
reserved: Mapping[str, str],
|
|
1389
1499
|
*,
|
|
1390
1500
|
withhold: bool = False,
|
|
1501
|
+
candidates: frozenset[str] | None = None,
|
|
1391
1502
|
) -> str:
|
|
1392
1503
|
"""Land whatever this run produced, and say what happened — never raising into the model."""
|
|
1393
1504
|
sink = session.output_sink
|
|
@@ -1419,6 +1530,8 @@ async def _collect(
|
|
|
1419
1530
|
reserved=reserved,
|
|
1420
1531
|
guest_prefix=guest_prefix,
|
|
1421
1532
|
normalization=_normalization(session),
|
|
1533
|
+
named_by=_MANIFEST_FILENAME,
|
|
1534
|
+
candidates=candidates,
|
|
1422
1535
|
)
|
|
1423
1536
|
if isinstance(checked, str):
|
|
1424
1537
|
return checked
|
|
@@ -1447,7 +1560,14 @@ async def _collect(
|
|
|
1447
1560
|
except Exception as exc: # noqa: BLE001
|
|
1448
1561
|
logger.warning("execute_code: saving this run's files failed: %s", error_detail(exc))
|
|
1449
1562
|
return f"Error: the program ran but its files could not be saved. {_MAY_HAVE_LANDED}"
|
|
1450
|
-
return _format_landed(
|
|
1563
|
+
return _format_landed(
|
|
1564
|
+
landed,
|
|
1565
|
+
declared,
|
|
1566
|
+
withhold=withhold,
|
|
1567
|
+
named_by=_MANIFEST_FILENAME if outputs is CodeactOutputs.MANIFEST else _OUTPUTS_ARGUMENT,
|
|
1568
|
+
argument=None if outputs is CodeactOutputs.MANIFEST else _OUTPUTS_ARGUMENT,
|
|
1569
|
+
candidates=candidates,
|
|
1570
|
+
)
|
|
1451
1571
|
|
|
1452
1572
|
|
|
1453
1573
|
async def _read_manifest(
|
|
@@ -1531,7 +1651,13 @@ async def _read_manifest(
|
|
|
1531
1651
|
|
|
1532
1652
|
|
|
1533
1653
|
def _format_landed(
|
|
1534
|
-
landed: Sequence[LandedArtifact],
|
|
1654
|
+
landed: Sequence[LandedArtifact],
|
|
1655
|
+
declared: Sequence[str],
|
|
1656
|
+
*,
|
|
1657
|
+
withhold: bool = False,
|
|
1658
|
+
named_by: str = _OUTPUTS_ARGUMENT,
|
|
1659
|
+
argument: str | None = None,
|
|
1660
|
+
candidates: frozenset[str] | None = None,
|
|
1535
1661
|
) -> str:
|
|
1536
1662
|
"""What the model is told about the files: what landed, and what is absent.
|
|
1537
1663
|
|
|
@@ -1541,24 +1667,59 @@ def _format_landed(
|
|
|
1541
1667
|
Normalizing **both** sides is right whichever normalization the sink chose — under
|
|
1542
1668
|
``NameNormalization.NONE`` the two are already the same string.
|
|
1543
1669
|
|
|
1670
|
+
``argument`` names the parameter ``declared`` came from, where it came from one — the
|
|
1671
|
+
``outputs`` argument, never the manifest, which no caller spelled. It is what makes the
|
|
1672
|
+
provenance answer exact here rather than inferred, and it matters as much after the run as
|
|
1673
|
+
before it: without it a declared name equal to hidden content renders as a position, and a
|
|
1674
|
+
caller watching which way its own spelling comes back learns that the guess was right.
|
|
1675
|
+
|
|
1544
1676
|
``withhold`` drops ``display`` in favour of the name the model itself declared. The sink
|
|
1545
1677
|
composes ``display`` from an :class:`~maf_sandbox.Artifact` whose ``content`` is the guest's
|
|
1546
1678
|
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
|
-
|
|
1679
|
+
any of that content in the string would be putting guest-authored text back into a result
|
|
1680
|
+
rendered to hold none. Naming the declared spelling costs the sink's own detail and needs no
|
|
1681
|
+
promise from the host to stay honest.
|
|
1550
1682
|
"""
|
|
1551
1683
|
delivered = {unicodedata.normalize("NFC", item.name) for item in landed}
|
|
1552
1684
|
lines: list[str] = []
|
|
1685
|
+
# Answered once, above the two renderings that need it: a name that landed is reported as
|
|
1686
|
+
# surely as one that did not, and a name the framework substituted must not be repeated
|
|
1687
|
+
# either way. `withhold` makes no difference to that — it is the mode that renders least.
|
|
1688
|
+
rewritten = positions_holding_hidden_content(
|
|
1689
|
+
list(declared), argument=argument, candidates=candidates
|
|
1690
|
+
)
|
|
1691
|
+
position_of = {
|
|
1692
|
+
unicodedata.normalize("NFC", name): position for position, name in enumerate(declared)
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1553
1695
|
if landed:
|
|
1554
1696
|
lines.append("Saved:")
|
|
1555
1697
|
if withhold:
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1698
|
+
for position, name in enumerate(declared):
|
|
1699
|
+
if unicodedata.normalize("NFC", name) in delivered:
|
|
1700
|
+
# The name itself unless the framework put it there: this is a list of
|
|
1701
|
+
# files, not a refusal, so a name of the model's own is what it wants back.
|
|
1702
|
+
lines.append(
|
|
1703
|
+
f"- {named_by}[{position}]" if position in rewritten else f"- {name}"
|
|
1704
|
+
)
|
|
1559
1705
|
else:
|
|
1560
|
-
|
|
1561
|
-
|
|
1706
|
+
for item in landed:
|
|
1707
|
+
# The display carries the name inside it, so a substituted one is reported by
|
|
1708
|
+
# position instead. A name of the model's own keeps the size and the rest of it.
|
|
1709
|
+
position = position_of.get(unicodedata.normalize("NFC", item.name))
|
|
1710
|
+
lines.append(
|
|
1711
|
+
f"- {named_by}[{position}]"
|
|
1712
|
+
if position is not None and position in rewritten
|
|
1713
|
+
else f"- {item.display}"
|
|
1714
|
+
)
|
|
1715
|
+
# A name that produced no file is reported the way a refusal reports one: the caller's
|
|
1716
|
+
# spelling where it is the caller's, and the position where the framework put something
|
|
1717
|
+
# else there. It is the one line here that names a file which does not exist.
|
|
1718
|
+
missing = [
|
|
1719
|
+
echoed_name(name, at=f"{named_by}[{position}]", hidden=position in rewritten)
|
|
1720
|
+
for position, name in enumerate(declared)
|
|
1721
|
+
if unicodedata.normalize("NFC", name) not in delivered
|
|
1722
|
+
]
|
|
1562
1723
|
if missing:
|
|
1563
1724
|
lines.append(
|
|
1564
1725
|
f"Not written by the program, so not saved: {', '.join(sorted(missing))}. Write "
|
|
@@ -1597,31 +1758,31 @@ def _stream_bytes(text: str | None) -> int:
|
|
|
1597
1758
|
return len((text or "").encode("utf-8", errors="surrogatepass"))
|
|
1598
1759
|
|
|
1599
1760
|
|
|
1600
|
-
def _format_withheld(result: ExecResult
|
|
1761
|
+
def _format_withheld(result: ExecResult) -> str:
|
|
1601
1762
|
"""Render one run for a host that withholds guest text: sizes, not content.
|
|
1602
1763
|
|
|
1603
1764
|
Fixed shape and fixed order, empty streams included, and the exit code named on every run
|
|
1604
1765
|
unlike :func:`_format_result`'s — with the content gone it is the only thing left that says
|
|
1605
1766
|
whether the program worked.
|
|
1606
1767
|
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1768
|
+
The route sentence is not here: it closes every result a withholding tool returns, this
|
|
1769
|
+
one included, and is attached where that is true of all of them.
|
|
1770
|
+
|
|
1771
|
+
``ExecResult.producer_owns_stderr`` decides who owns ``stderr``. Where it is set that
|
|
1772
|
+
field is the producer's, not the guest's, and holds its note about the run — the one that
|
|
1773
|
+
tells a dropped output apart from a program that printed nothing. Withholding it would
|
|
1774
|
+
report the first as the second, so it is surfaced whole and only ``stdout`` is sized.
|
|
1612
1775
|
"""
|
|
1613
|
-
if
|
|
1776
|
+
if result.producer_owns_stderr:
|
|
1614
1777
|
note = (result.stderr or "").rstrip("\n")
|
|
1615
1778
|
lines = [f"exit code: {result.exit_code}", f"output: {_stream_bytes(result.stdout)} bytes"]
|
|
1616
1779
|
if note:
|
|
1617
1780
|
lines.append(f"note: {note}")
|
|
1618
|
-
lines.append(_WITHHELD_ROUTE)
|
|
1619
1781
|
return "\n".join(lines)
|
|
1620
1782
|
return "\n".join(
|
|
1621
1783
|
(
|
|
1622
1784
|
f"exit code: {result.exit_code}",
|
|
1623
1785
|
f"stdout: {_stream_bytes(result.stdout)} bytes",
|
|
1624
1786
|
f"stderr: {_stream_bytes(result.stderr)} bytes",
|
|
1625
|
-
_WITHHELD_ROUTE,
|
|
1626
1787
|
)
|
|
1627
1788
|
)
|
|
File without changes
|
{maf_sandbox_codeact-0.9.2 → maf_sandbox_codeact-0.11.0}/src/maf_sandbox_codeact/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|