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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maf-sandbox-codeact
3
- Version: 0.9.2
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.27.0,<0.30
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`.** The library's default is `"trusted"`, which is right for a workload whose result is a compiler's own diagnostics and wrong for this one: what comes back is whatever a model-written `print(...)` chose to emit. Undeclared, MAF's information-flow tracker applies its untrusted default and the result taints the conversation — the fail-safe direction, and the honest one.
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
- **`withhold_guest_output=True` changes that, by changing what comes back.** 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, so the tool declares `source_integrity="trusted"` and a host doing information-flow control gets a result it can label without either lying about it or tainting the conversation. 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.
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 the claim is that no guest-authored *text* crosses, not that no guest-chosen *bit* does. 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.
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**, and that is why the claim holds. `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 into a result labelled trusted. 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.
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`.** The library's default is `"trusted"`, which is right for a workload whose result is a compiler's own diagnostics and wrong for this one: what comes back is whatever a model-written `print(...)` chose to emit. Undeclared, MAF's information-flow tracker applies its untrusted default and the result taints the conversation — the fail-safe direction, and the honest one.
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
- **`withhold_guest_output=True` changes that, by changing what comes back.** 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, so the tool declares `source_integrity="trusted"` and a host doing information-flow control gets a result it can label without either lying about it or tainting the conversation. 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.
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 the claim is that no guest-authored *text* crosses, not that no guest-chosen *bit* does. 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.
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**, and that is why the claim holds. `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 into a result labelled trusted. 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.
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.9.2"
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.0,<0.30",
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.9.2"
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.27.0,<0.30",
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 SandboxToolSession, sandboxed_tool
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 run's directory is created — a dedicated root rather than the image's own tree.
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 run's own directory.
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. A sentence rather than a silence, because
129
- #: the exit code alone leaves a model nothing to act on; it names the route without promising a
130
- #: reader, which is the host's wiring rather than this kind's to claim.
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, so this tool declares
254
- :data:`~maf_sandbox.SourceIntegrity.TRUSTED` — unless a wired registry's
255
- ``result_integrity`` says otherwise, which is core's fold to make and is honoured
256
- here. Requires :data:`CodeactOutputs.DECLARED`: the one mode where content can still
257
- reach the model and no guest-chosen name reaches the result.
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
- **What "trusted" claims, exactly.** The prose and the shape are this package's, and
272
- the artifact names are the model's own — but what fills them is the program's to
273
- choose, and it is a channel rather than a leak-free boundary. The exit status is 8
274
- bits; each stream's size is a few more, chosen by padding; and **each declared
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 the claim is that no guest-authored *text* crosses, not that no
278
- guest-chosen *bit* does. That is a narrow per-call channel rather than the open one
279
- a rendered ``stdout`` is, and a host that must close it should not attach this
280
- workload at all. The sink's ``display`` is deliberately *not* rendered here — see
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
- # The library's "trusted" default is right for a compiler's diagnostics and wrong here:
461
- # what comes back is whatever a model-written `print(...)` chose to emit. Undeclared,
462
- # the tracker's untrusted default applies and the result taints the conversation.
463
- # Withholding removes that premise — no guest-authored text survives into the result —
464
- # so the declaration becomes available, subject to the registry's own fold below.
465
- source_integrity=_withheld_integrity(surface) if withhold_guest_output else None,
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
- **What you print is not read back.** You get the exit code and how large the output
624
- was, never what was in it — so ``print(...)`` is for your own debugging and never a way
625
- to return a value. Write anything you need to see into a declared output instead.
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(code: str, files: list[str] | None, declared: list[str] | None) -> str:
836
- return await _execute(
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 13 bytes of the 255 a name gets.
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
- run_id = call_directory.rsplit("/", 1)[-1]
928
- # Where the model's own files live, relative to `work_dir`: the run directory itself, or
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 run's layout.
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
- # `run_id`: five more bytes of the 255 a guest path gets, spent before the name is.
934
- guest_prefix = f"{run_id}/{WORK_DIRECTORY}" if host_tool_call is not None else run_id
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
- resolved = await _resolve_listed_files(
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(resolved, str):
973
- return resolved
974
- read = await _read_listed_files(store, resolved, tally)
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 run that
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 run directory flat, which is what a kind
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. The message also carries the output clause, which is the program's stdout or
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 output clause is in the message rather than fenced off in `output`, so the
1055
- # sentence is rebuilt from the attributes instead — and it names no bound, because
1056
- # *whose* expired is not knowable here. A backend may raise this public type from a
1057
- # call of its own, the transport propagates that untranslated, and the subtype that
1058
- # tells the two apart is core's private one. `signal` is the discriminator the
1059
- # exception does carry, and `"absent"` is its one value asserting nothing started.
1060
- if expired.signal == "absent":
1061
- return f"Error: the time ran out before the program was started. {_WITHHELD_ROUTE}"
1062
- return f"Error: the program did not finish in the time it was given. {_WITHHELD_ROUTE}"
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. The route still belongs on the end —
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
- expiry = f"Error: the program timed out after {timeout}s"
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, sandbox, guest_prefix, outputs, names, reserved, withhold=withhold
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
- ) -> list[str] | str:
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
- known = set(listing)
1131
- resolved: list[str] = []
1132
- for name in files:
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: {name!r} cannot be shared — {exc}"
1206
+ return f"Error: {named} cannot be shared — {exc}"
1141
1207
  if name in reserved:
1142
- return f"Error: {name!r} cannot be shared — {reserved[name]}."
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 resolved:
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: {name!r} was listed twice."
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: {name!r} is not in this tool's file listing, so it was not shared. "
1159
- f"{_listing_hint(name, listing, withhold=withhold)}"
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
- resolved.append(name)
1162
- return resolved
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 declared
1176
- :data:`~maf_sandbox.SourceIntegrity.TRUSTED`, for a name the model never asked about.
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
- store: AgentFileStore, names: list[str], tally: _InboundTally
1192
- ) -> list[tuple[str, str]] | str:
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
- Text only: ``AgentFileStore.read`` answers with ``str``, and this path encodes what it
1200
- is given. The protocol's ``write_file`` takes ``bytes``, so the boundary below is not what
1201
- stands in the way of a binary input — but this function and the tally would both have to
1202
- learn about it.
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 name in names:
1206
- try:
1207
- content = await store.read(name)
1208
- except Exception as exc: # noqa: BLE001
1209
- logger.warning("execute_code: could not read %r from the file store: %s", name, exc)
1210
- return f"Error: could not read {name!r} from the file store"
1211
- if content is None:
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: %r is listed but has no content", name)
1216
- return f"Error: {name!r} is listed in the file store but has no content"
1217
- over_cap = tally.add(name, content)
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(name: str, reserved: Mapping[str, str], *, action: str) -> str | None:
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!r} cannot be {action} — {above!r} is a file name this tool reserves in "
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: {name!r} is not valid UTF-8 and cannot be written into the sandbox. "
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: {name!r} is {size} bytes and this tool writes at most "
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 {name!r} into the sandbox"
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
- for name in names:
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: {name!r} cannot be saved — {exc}"
1472
+ return f"Error: {named} cannot be saved — {exc}"
1363
1473
  if name in reserved:
1364
- return f"Error: {name!r} cannot be saved — {reserved[name]}."
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: {name!r} and {earlier!r} are one file once saved"
1484
+ f"Error: {named} and {earlier_named} are one file once saved"
1375
1485
  if earlier != name
1376
- else f"Error: {name!r} was declared twice."
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(landed, declared, withhold=withhold)
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], declared: Sequence[str], *, withhold: bool = False
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 this
1548
- kind has declared :data:`~maf_sandbox.SourceIntegrity.TRUSTED`. Naming the declared spelling
1549
- costs the sink's own detail and needs no promise from the host to stay honest.
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
- lines.extend(
1557
- f"- {name}" for name in declared if unicodedata.normalize("NFC", name) in delivered
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
- lines.extend(f"- {item.display}" for item in landed)
1561
- missing = [name for name in declared if unicodedata.normalize("NFC", name) not in delivered]
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, *, over_transport: bool) -> str:
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
- ``over_transport`` decides who owns ``stderr``. On the host-tool-call transport the
1608
- launcher merges the guest's stderr into its output file, so that field is the *host's* and
1609
- holds its note about the run — the one that tells a dropped output apart from a program
1610
- that printed nothing. Withholding it would report the first as the second, so it is
1611
- surfaced whole there and only the merged stream is reduced to a size.
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 over_transport:
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
  )