maf-sandbox-codeact 0.9.2__tar.gz → 0.10.0__tar.gz

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