From f1db17b3f825a090aee29d4104601dced76992e7 Mon Sep 17 00:00:00 2001 From: Jithin VG Date: Wed, 11 Nov 2026 09:36:55 +0530 Subject: [PATCH] An absent allow-egress draws no boundary A workspace that writes no `allow-egress:` was read two ways: the tool egress and meaning-search checks treated it as "this author has not decided", while the model-binding check (`loader/leaves-the-box`), the Python adapter's `egress.admits` (and so `missing_for`, which `resolve` filters candidates with) and a carried program's network treated it as air-gapped, refusing hosted models in trees that never opted in and quoting an `allow-egress: []` the author never wrote. Read it one way: absent draws no boundary, so every part may talk outside; `allow-egress: []` still means nothing may leave, and every written list is held exactly as before. The air-gapped desk of D17 is a declaration, `allow-egress: []`. - egress.rs: `refusals` returns for a workspace with no line. - egress.py: `admits` answers yes when the line is absent. - ir.py: a carried program may reach outside when the line is absent; the run report says "names `programs` or is not written". - spec/schema.yaml: the field's help and comment state both readings. - Tests: a new checker test (absent binds a hosted model; `[]` refuses it; `[llm]` admits it), a Python test for the absent case, and the sealed-room fixture now writes `allow-egress: []`. --- CHANGELOG.md | 10 +++ adapters/python/src/pact_adapters/egress.py | 7 ++ adapters/python/src/pact_adapters/harness.py | 4 +- adapters/python/src/pact_adapters/ir.py | 13 ++- ..._a_stage_that_runs_what_the_model_wrote.py | 24 +++++- crates/pact-cli/src/egress.rs | 17 +++- ...that_writes_no_boundary_binds_any_model.rs | 79 +++++++++++++++++++ spec/schema.yaml | 17 ++-- 8 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 crates/pact-cli/tests/a_workspace_that_writes_no_boundary_binds_any_model.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d119e..c577dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,16 @@ What changed in PACT, newest first. Each entry says what an author can now write, or what now holds that did not; the commits are listed under it for whoever wants the detail. +## 2026-11 — an absent `allow-egress:` draws no boundary + +A workspace that writes no `allow-egress:` is no longer read as air-gapped: every part of it may +talk outside, as `nothing_reaches_outside_the_box` and the meaning-search check already read it. +`allow-egress: []` still means nothing may leave, and every list is held exactly as before. The +binding check (`loader/leaves-the-box`), the Python adapter's `egress.admits` and `missing_for` +(which `resolve` filters model candidates with), the judge check and a carried program's network +follow the one reading. An author who wants the air-gapped desk of D17 writes `allow-egress: []`; +constraining is a declaration, and leaving it out is not one. + ## 2026-11 — the seven primitives, built (10 November) `docs/97-PRIMITIVES.md` names seven building blocks (Shape, Origin, Store, Actor, Flow, Check, diff --git a/adapters/python/src/pact_adapters/egress.py b/adapters/python/src/pact_adapters/egress.py index 2e1aedd..ca18801 100644 --- a/adapters/python/src/pact_adapters/egress.py +++ b/adapters/python/src/pact_adapters/egress.py @@ -118,12 +118,19 @@ def listed(document: dict[str, Any]) -> str: def admits(document: dict[str, Any], *roles: str) -> bool: """Does this workspace let a binding playing any of `roles` talk outside? + A workspace that writes no `allow-egress:` draws no boundary, so it lets + everything out; one that writes `allow-egress: []` lets nothing out. + Several roles because some bindings have more than one honest answer: a `graded-by:` is admitted by `judge` or by `llm`, and an agent whose `needs: audio: yes` says only that it must "hear or speak" is admitted by `stt` or by `tts` — demanding both would refuse a transcription agent for a reply it never speaks. """ + # An absent `allow-egress:` draws no boundary, as the checker reads it; + # `[]` is the air-gapped desk and admits nothing. + if "allow-egress" not in document: + return True have = granted(document) for role in roles: if role in have: diff --git a/adapters/python/src/pact_adapters/harness.py b/adapters/python/src/pact_adapters/harness.py index 86308b2..3e78b14 100644 --- a/adapters/python/src/pact_adapters/harness.py +++ b/adapters/python/src/pact_adapters/harness.py @@ -1418,7 +1418,7 @@ def _charge_summary(cost: "tuple[int, float] | None") -> None: many = len(spec.programs) != 1 result.unenforced = result.unenforced + ( ( - f"`allow-egress:` names `programs`, so {named} " + f"`allow-egress:` names `programs` or is not written, so {named} " f"{'are' if many else 'is'} allowed to reach outside this box. What " f"{'they' if many else 'it'} can actually reach is the room's, and " f"PACT never opens a carried body or watches the room." @@ -1969,7 +1969,7 @@ def _charge_summary(cost: "tuple[int, float] | None") -> None: ( f"stage {phase_name!r} writes code to be run, and a hiding " f"rule changed it before it ran — `allow-egress:` names " - f"`programs`, so the room may reach outside and is given the " + f"`programs` or is not written, so the room may reach outside and is given the " f"hidden form rather than what the model wrote. What it " f"worked out may not be what was asked for." ) diff --git a/adapters/python/src/pact_adapters/ir.py b/adapters/python/src/pact_adapters/ir.py index 38d2c01..6b26b6b 100644 --- a/adapters/python/src/pact_adapters/ir.py +++ b/adapters/python/src/pact_adapters/ir.py @@ -366,7 +366,8 @@ class ProgramSpec: #: The tool actions that reach it, `/`, so the sentence a run #: reports can name where the author wrote it. reached_by: tuple[str, ...] = () - #: Whether this workspace's `allow-egress:` names `programs`. + #: Whether this workspace's `allow-egress:` names `programs`, or is not + #: written, which draws no boundary. #: #: The eighth part of the boundary, and for a round it was decoration: no #: check read the word, no run reported it, and this class — the only thing a @@ -724,7 +725,8 @@ class AgentSpec: #: another agent's tool reaches is not something this run could have called, #: so reporting it here would be a sentence about somebody else's document. programs: tuple[ProgramSpec, ...] = () - #: Whether this workspace's `allow-egress:` names `programs`, as a fact about + #: Whether this workspace's `allow-egress:` names `programs` or is not + #: written, as a fact about #: the WORKSPACE rather than about one carried body. #: #: The same word `ProgramSpec.may_reach_outside` carries, read once and put in @@ -1108,13 +1110,18 @@ def walk(node: Any) -> None: def _programs_may_reach_outside(doc: dict[str, Any]) -> bool: - """Does this workspace's `allow-egress:` name `programs`? + """Does this workspace's `allow-egress:` name `programs`, or not exist? + + A workspace that writes no `allow-egress:` draws no boundary, so its + programs may reach outside as everything else may. One reader, two carriers: `ProgramSpec.may_reach_outside` for a host holding one body, and `AgentSpec.programs_may_reach_outside` for a `does: run-code` stage, which names no body and still has to know whether the room it is about to use may reach outside. """ + if "allow-egress" not in doc: + return True return "programs" in [str(w).strip() for w in _as_list(doc.get("allow-egress"))] diff --git a/adapters/python/tests/test_a_stage_that_runs_what_the_model_wrote.py b/adapters/python/tests/test_a_stage_that_runs_what_the_model_wrote.py index a22ffaf..c90b1ee 100644 --- a/adapters/python/tests/test_a_stage_that_runs_what_the_model_wrote.py +++ b/adapters/python/tests/test_a_stage_that_runs_what_the_model_wrote.py @@ -213,9 +213,12 @@ def test_a_run_code_stage_with_no_room_says_so() -> None: #: The same desk with the workspace's one redaction file beside it. `hide:` is #: the workspace-wide spelling of the same act an interceptor's `rules:` writes, -#: and its own promise is *"what must never leave this workspace"*. +#: and its own promise is *"what must never leave this workspace"*. It writes +#: `allow-egress: []`, so the room is inside the boundary: a workspace that +#: writes no `allow-egress:` draws none, and its room may reach outside. def _guarded() -> dict: doc = {k: dict(v) if isinstance(v, dict) else v for k, v in CODEACT.items()} + doc["allow-egress"] = [] doc["redaction"] = { "description": "Never let a card number out.", "hide": ["anything that looks like a card number"], @@ -380,3 +383,22 @@ def test_a_workspace_with_no_hiding_rules_hands_the_room_exactly_what_was_writte result = _run(run_program=lambda name, args: got.append(args["code"]) or "45.0") assert got and got[0] == "total = 40.00 + 5.00\nprint(total)", got assert not any("hiding rule changed it" in u for u in result.unenforced), result.unenforced + + +def test_where_no_boundary_is_written_the_masked_snippet_is_what_runs() -> None: + """A workspace that writes no `allow-egress:` draws no boundary. + + So its room may reach outside exactly as under `allow-egress: [programs]`: + the masked text is what runs, and the report says why. + """ + doc = _guarded() + del doc["allow-egress"] + got: list[str] = [] + result = asyncio.run( + run(AgentSpec.from_document(doc, "desk"), + ReferenceTransport(Script([Turn('x = 9780306406157\nprint(x)'), Turn("Done.")])), + "how much do we refund?", {}, + run_program=lambda name, args: got.append(args["code"]) or "0") + ) + assert got and "9780306406157" not in got[0], got + assert "is not written" in " ".join(result.unenforced), result.unenforced diff --git a/crates/pact-cli/src/egress.rs b/crates/pact-cli/src/egress.rs index bedd1c9..48e93cf 100644 --- a/crates/pact-cli/src/egress.rs +++ b/crates/pact-cli/src/egress.rs @@ -963,9 +963,17 @@ fn grants(roles: &[&str]) -> String { /// offers a fix in another one — R56, in a document the author is already being /// told to put a workspace around. Asked of the schema rather than by comparing /// `kind` to a literal, so a second kind of document that can draw a boundary is -/// held to it the day it declares the field. An absent `allow-egress:` in a -/// document that COULD have one still means nothing may leave: that is the -/// air-gapped default (D17), and it is the answer this has always given. +/// held to it the day it declares the field. +/// +/// # A workspace that writes no boundary is not held to one +/// +/// An absent `allow-egress:` means this workspace has drawn no boundary, the +/// same answer `nothing_reaches_outside_the_box` and +/// `looking_up_by_meaning_needs_an_embedder` in `main.rs` give: a default that +/// refused every hosted model refused trees that never opted in, and an author +/// who wants the air-gapped desk of D17 says so with `allow-egress: []`, which +/// is held exactly as before. Constraining is a declaration; leaving it out is +/// not one. pub fn refusals( root: &Node, kind: &str, @@ -981,6 +989,9 @@ pub fn refusals( { return; } + if root.get("allow-egress").is_none() { + return; + } let written = listed(root); let off_box = |node: &Node| { node.as_str() diff --git a/crates/pact-cli/tests/a_workspace_that_writes_no_boundary_binds_any_model.rs b/crates/pact-cli/tests/a_workspace_that_writes_no_boundary_binds_any_model.rs new file mode 100644 index 0000000..5021ae1 --- /dev/null +++ b/crates/pact-cli/tests/a_workspace_that_writes_no_boundary_binds_any_model.rs @@ -0,0 +1,79 @@ +//! A workspace that writes no `allow-egress:` draws no boundary, for models too. +//! +//! Two of the three checks that read `allow-egress:` already answered an absent +//! line with "this author has not decided" (`a_workspace_that_drew_no_boundary_is_not_given_one` +//! in `one_file_says_what_reaches_outside.rs`), while the model-binding check +//! answered it with "nothing may leave": a tree with no line, pinning a hosted +//! model, was refused with `loader/leaves-the-box` quoting `allow-egress: []`, a +//! line its author never wrote. Constraining is a declaration: the air-gapped +//! desk writes `allow-egress: []`, and that is held exactly as before. +//! +//! Each case runs the real `pact check` over files on disk, with a CONTROL: the +//! same tree under `allow-egress: []` must be refused, so the pass is not a +//! tree that fails to bind a model at all. + +use std::process::Command; + +fn pact() -> Command { + Command::new(env!("CARGO_BIN_EXE_pact")) +} + +/// A one-agent workspace pinning a model served only off this machine, with the +/// given `allow-egress:` line (or none). +fn desk(name: &str, egress: Option<&str>) -> std::path::PathBuf { + let dst = std::env::temp_dir().join(format!("pact-no-boundary-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dst); + std::fs::create_dir_all(dst.join("agents/desk")).unwrap(); + let line = egress.map(|e| format!("allow-egress: {e}\n")).unwrap_or_default(); + std::fs::write( + dst.join("workspace.yaml"), + format!("name: Probe\ndescription: A probe workspace.\n{line}"), + ) + .unwrap(); + std::fs::write( + dst.join("agents/desk/agent.yaml"), + "name: Desk\ndescription: Answers.\ninstructions: Answer the question.\nmodel: claude-opus-5\n", + ) + .unwrap(); + dst +} + +fn check(root: &std::path::Path) -> (bool, String) { + let out = pact() + .args(["check", root.to_str().unwrap()]) + .output() + .expect("runs"); + let said = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + (out.status.success(), said) +} + +#[test] +fn a_hosted_model_binds_where_no_boundary_is_written() { + let dst = desk("absent", None); + let (ok, said) = check(&dst); + assert!(ok, "{said}"); + assert!(!said.contains("loader/leaves-the-box"), "{said}"); + let _ = std::fs::remove_dir_all(&dst); +} + +#[test] +fn the_same_model_is_refused_where_nothing_may_leave() { + let dst = desk("empty", Some("[]")); + let (ok, said) = check(&dst); + assert!(!ok, "{said}"); + assert!(said.contains("loader/leaves-the-box"), "{said}"); + assert!(said.contains("allow-egress: []"), "{said}"); + let _ = std::fs::remove_dir_all(&dst); +} + +#[test] +fn a_list_that_names_the_role_admits_it() { + let dst = desk("llm", Some("[llm]")); + let (ok, said) = check(&dst); + assert!(ok, "{said}"); + let _ = std::fs::remove_dir_all(&dst); +} diff --git a/spec/schema.yaml b/spec/schema.yaml index fc44de1..9b927c2 100644 --- a/spec/schema.yaml +++ b/spec/schema.yaml @@ -201,17 +201,22 @@ groups: # this file said seven and that list said six for a round, and the word # in the gap loaded here and was silently read as `llm` there. # `programs` is the EIGHTH part, and it is a part rather than a model - # role for the same reason `tools` is. A carried program has no network - # unless this line says so: without it a program runs in a room with the - # door shut, which is the state a reviewer should be able to assume by - # reading nothing. + # role for the same reason `tools` is. Under a written `allow-egress:`, a + # carried program has no network unless this line names `programs`. + # + # Absent means no boundary is drawn, and every part may talk outside; + # `[]` means nothing may. A default that refused every hosted model + # refused trees that never opted in, and two of the three checks that + # read this line already treated absence that way. Constraining is a + # declaration. choices: [llm, stt, tts, embedder, judge, reflector, tools, programs] surface: S-EXEC tier: core help: > which parts of this system are allowed to talk to something outside - this box. Empty means nothing is. Adding a role here always needs a - person to approve it. + this box. Empty means nothing is; leaving it out draws no boundary, + so every part is. Adding a role here always needs a person to + approve it. agents: type: map of group:agent surface: S-TOPO