Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions adapters/python/src/pact_adapters/egress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions adapters/python/src/pact_adapters/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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."
)
Expand Down
13 changes: 10 additions & 3 deletions adapters/python/src/pact_adapters/ir.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,8 @@ class ProgramSpec:
#: The tool actions that reach it, `<tool>/<action>`, 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"))]


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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
17 changes: 14 additions & 3 deletions crates/pact-cli/src/egress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
17 changes: 11 additions & 6 deletions spec/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading