Skip to content
Merged
5 changes: 3 additions & 2 deletions cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1200,8 +1200,9 @@ def _add_task(sub) -> None:
"within about half a minute, on the provider's next lease renewal — and on a provider "
"that has not been updated yet it runs to completion, harmlessly, with nothing waiting "
"on it.\n\n"
"Nothing is rewound: whatever the agent had already done is kept, so "
"`grid task fetch` still works on it."),
"Nothing is undone. What the agent had reached is another matter: it is stopped "
"part-way and may never have published anything, so `grid task fetch` gives you "
"what the grid has recorded — which can be only what you sent in. It says which."),
formatter_class=argparse.RawDescriptionHelpFormatter)
cancel.add_argument(
"task_id",
Expand Down
37 changes: 29 additions & 8 deletions cli/remote_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,13 @@ def _no_trunk_message(args: argparse.Namespace, project_id: str) -> str:
"""
import shlex

head = (f"Project {project_id} has no main yet, so there is nothing to cut a task from.")
# ⚠️ No git word (ADR 0034 D-m, issue 46). This said "has no main yet" and was the FIRST wall a
# new user meets — `project create` then `task create` — while `tests/test_application_surface`
# reported green, because the literal lives in this local and the scan's walker only followed
# constants handed straight to a sink (ND-06). The walker follows locals now; the wording is
# the other half. "Ready to work in" is `_project_ready`'s own phrase for the state this one is
# the absence of, so the two sides of that coin read as one thing.
head = (f"Project {project_id} has no files yet, so there is nothing to start a task from.")
if getattr(args, "init_project", False):
# The caller ALREADY asked for a trunk and the relay still says there is none, so offering
# `--init-project` would hand back the command that just failed — the "advice that is
Expand Down Expand Up @@ -1333,9 +1339,9 @@ def _task_fetch(args: argparse.Namespace) -> int:
f"Task {args.task_id} finished as {state} but recorded no result to fetch. "
f"`grid task get {args.task_id}` shows what it did report.")
# A missing `result_commit` used to be refused here too, and that made `grid task cancel` a
# liar: it prints "Its branch is left where the agent got to: grid task fetch <id>", and this
# command then answered "recorded no result to fetch" — while the branch was on the relay all
# along, holding at least the task's input.
# liar: cancel pointed the user straight at this command, and this command then answered
# "recorded no result to fetch" — while the branch was on the relay all along, holding at
# least the task's input. (Cancel's own wording was the other half, fixed under ND-02.)
#
# The promise cannot be made conditional at its own end: cancel returns immediately and the
# agent does not die until the next lease beat, so at the moment the sentence is printed nobody
Expand Down Expand Up @@ -1630,10 +1636,25 @@ def _task_cancel(args: argparse.Namespace) -> int:
reason = answer.get("error")
print(f"task {args.task_id} cancelled — it is now {state}"
+ (f" ({reason})" if reason else ""))
# Nothing is rewound, so whatever the agent had done is still fetchable. Said out loud,
# because "cancelled" reads as "undone" and here it is not. No git word (ADR 0034 D-m,
# issue 46) — the parser's own description was reworded with it.
print(f"Whatever it had already done is kept: grid task fetch {args.task_id}")
# Nothing is REWOUND, and that is all this command can honestly claim. Said out loud, because
# "cancelled" reads as "undone" and here it is not. No git word (ADR 0034 D-m, issue 46) — the
# parser's own description was reworded with it.
#
# ⚠️ This used to say "Whatever it had already done is kept", and that was a promise this line
# is in no position to make (ND-02). `cancel` returns as soon as the relay records it while the
# agent runs on until the next lease beat, so at the moment these words are printed nobody
# knows whether a result will ever land — and measured on a live grid, the tree a subsequent
# `fetch` returned was the task's INPUT, with none of the agent's edits in it. `_task_fetch`
# already says which of the two it served ("recorded no result … it may hold only the task's
# input"); this end now stops contradicting it in advance.
#
# ⚠️ The command goes LAST on its own line, which is a house rule with a test behind it:
# `test_task_lease.test_every_command_this_cli_tells_you_to_run_actually_parses` reads a
# printed hint as `grid (task|project) …` to the end of the LINE and retypes it, so prose
# trailing the command becomes argv and the parser refuses what this CLI just recommended.
print(f"Nothing is undone. Whether the agent published anything before it stopped is another "
f"matter — this gives you what the grid has recorded, and says which it is:\n"
f" grid task fetch {args.task_id}")
return 0


Expand Down
11 changes: 11 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1201,6 +1201,17 @@ exactly the documents the relay sent:
{"error": {"code": "project_has_no_trunk", "message": "…", "status": 409}}
```

⚠️ **stderr is not only that document.** The envelope is written on its own line and the same
message then follows as a plain sentence, because a person watching the terminal must still be
told what happened — the envelope is an addition to stderr, not a replacement for it:

```
{"error": {"code": null, "message": "`grid task` is a remote-mode command. …", "status": null}}
`grid task` is a remote-mode command. …
```

So read the **first line** of stderr, rather than parsing the whole stream as one document.

`code` is the relay's own machine-readable slug, and is `null` for a refusal this CLI raised itself
or one from a relay too old to send one — which is ordinary, not an error: branch on `code` when it
is there and show `message` when it is not. `status` is the HTTP status when the relay answered and
Expand Down
25 changes: 24 additions & 1 deletion remote/relay.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,28 @@ def _task_error_message(resp: httpx.Response) -> str:
return f"Task request failed ({resp.status_code}): {resp.text[:400]}"


# A bare framework 404 from `POST /relay/v1/tasks` (ND-13).
#
# ⚠️ **Its own sentence, and pointedly NOT `_OLD_RELAY`'s diagnosis.** Every other missing-route
# hint in this module says "your relay predates this feature", because those routes really did
# arrive with a release. This one did not: `/relay/v1/tasks` exists on every relay that has ever
# had a task plane at all — an out-of-date relay answers **201** here and quietly files the task in
# the caller's own `default`, which is the failure `create_task`'s echoed-`project_id` guard
# catches. So a 404 from THIS route cannot mean an old relay, and saying so would send somebody to
# upgrade a server that is answering correctly.
#
# What it does mean is that whatever is on the other end of this address is not the relay: a proxy
# or gateway in front of it that does not forward the path, or a base URL pointing somewhere else
# entirely. Left untranslated the user's whole diagnosis was the words "Not Found" — the one
# command out of fourteen in the old-relay drill that leaked the framework's own string.
_NOT_THE_RELAY = (
"This grid's relay address answered 'not found' for the route every relay has, so what is "
"answering there is probably not the relay — usually a proxy in front of it that does not "
"pass the path on, or a base address pointing somewhere else. No task was created. "
"`grid status` shows the address in use."
)


def create_task(
signaling_url: str,
access_token: str,
Expand Down Expand Up @@ -907,7 +929,8 @@ def create_task(
body: dict[str, Any] = {"prompt": prompt, "project_id": project_id}
if files:
body["files"] = files
task = _task_oneshot(signaling_url, access_token, "POST", "/relay/v1/tasks", json=body)
task = _task_oneshot(signaling_url, access_token, "POST", "/relay/v1/tasks", json=body,
missing_route_hint=_NOT_THE_RELAY)

# The ONE way this feature can fail silently, and it is the exact bug it exists to kill.
# `/relay/v1/tasks` exists on a relay that predates project membership too, so it answers 201
Expand Down
25 changes: 17 additions & 8 deletions remote/task_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,20 +217,27 @@ def _safe_segment(kind: str, value: str) -> str:
minted — so "not a safe path segment" on its own would leave an operator with three things to
check and no way to tell which.

⚠️ **One NAME is refused as well as one shape** (ADR 0034 D-c, issue 50). The object store lives
at `<member_key>/store.git`, and `_SAFE_PROJECT_ID` admits a dot — so a conversation id spelled
exactly that is legal by every other rule here and would put a workspace on top of the member's
entire history. Refused in all three positions rather than only the one where the collision is
reachable: one rule is checkable, and "which levels does the store sit between" is the kind of
thing a later layout change moves.
⚠️ **One NAME — in any casing — is refused as well as one shape** (ADR 0034 D-c, issue 50; the
casing is ND-11). The object store lives at `<member_key>/store.git`, and `_SAFE_PROJECT_ID`
admits a dot — so a conversation id spelled exactly that is legal by every other rule here and
would put a workspace on top of the member's entire history. Refused in all three positions
rather than only the one where the collision is reachable: one rule is checkable, and "which
levels does the store sit between" is the kind of thing a later layout change moves.
"""
if not isinstance(value, str):
raise ValueError(f"{kind} must be a string, got {type(value).__name__}")
if not value or len(value) > _MAX_PROJECT_ID_CHARS:
raise ValueError(f"{kind} must be 1-{_MAX_PROJECT_ID_CHARS} characters, got {len(value)}")
if value in (".", "..") or not _SAFE_PROJECT_ID.match(value):
raise ValueError(f"{kind} {value!r} is not a single safe path segment")
if value == task_worktree.STORE_DIR_NAME:
if value.casefold() == task_worktree.STORE_DIR_NAME.casefold():
# ⚠️ **`casefold`, never `==`** (ND-11). APFS and NTFS are case-INsensitive by default, so
# `STORE.GIT` and `store.git` are ONE directory on the machines a provider actually runs
# on — measured on macOS: writing `store.git/marker` and reading `STORE.GIT/marker` gives
# the store's own content back. An `==` here therefore admitted the exact collision this
# guard exists to refuse, spelled differently, in all three positions.
# `Path.resolve()` does not normalise case either, so comparing resolved paths would miss
# it too; the comparison has to be on the NAME.
raise ValueError(
f"{kind} {value!r} is the object store's own directory name, so a workspace built from "
f"it would sit on top of this member's whole git history")
Expand Down Expand Up @@ -733,7 +740,9 @@ def preflight() -> None:
permission_mode()
_passthrough_env_names()
if task_sandbox.enabled():
task_sandbox.preflight()
# The root is handed over rather than read there: `task_sandbox` cannot import this module
# (it would close a cycle), and this is the one caller that already knows the answer.
task_sandbox.preflight(task_root=workspace_root())


def _require_version_for_the_sandbox(binary: str) -> None:
Expand Down
15 changes: 14 additions & 1 deletion remote/task_evict.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,21 @@ def _conversations(root: Path) -> list[tuple[tuple[str, str, str], Path]]:
if not projects.is_dir():
return found
try:
project_dirs = sorted(projects.iterdir())
# ⚠️ **Filtered to DIRECTORIES here, not left for `_subdirectories` to trip over** (ND-18).
# A non-directory under `projects/` is not a project and can never hold a candidate, but
# handing one down produced `NotADirectoryError` — a real listing failure as far as that
# function can tell — and with it a warning that this provider's workspace bound "is not
# being fully enforced". On macOS the file is `.DS_Store`, Finder writes it the moment
# anybody opens the folder, and the sweep went on enforcing the cap perfectly over every
# genuine project while saying once per sweep that it had stopped. The impact was nil and
# the sentence was alarming, which is the pair that teaches an operator to ignore it.
#
# Same predicate as `_subdirectories`, symlinks included: a symlinked project directory
# would let the sweep walk — and delete — through a link out of the tree entirely.
project_dirs = sorted(entry for entry in projects.iterdir()
if entry.is_dir() and not entry.is_symlink())
except OSError as exc:
# A failure to list `projects/` ITSELF is still a real one, and still says so.
_warn(f"could not list {projects} to bound the provider's workspaces ({exc})")
return found
for project in project_dirs:
Expand Down
66 changes: 65 additions & 1 deletion remote/task_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,26 @@ def home_directory() -> Path:
return home


def preflight() -> None:
def preflight(task_root: Path | None = None) -> None:
"""Check what `policy()` will need, early enough to be worth reporting.

`policy()` runs from `agent_argv`, which is built AFTER the task's checkout — so without this a
provider with a broken `HOME` fetches a repository, links a transcript, and only then fails with
a message about the task runner. Called from `task_agent.preflight()`.

`task_root` is passed IN rather than read here, for `_task_root_of`'s reason: `task_agent`
imports this module, so asking it for `workspace_root()` would close a cycle. `None` skips that
one check rather than guessing, which keeps every existing caller honest.

⚠️ **The configuration check runs BEFORE the socket proof**, which binds real sockets: an
operator whose `GRID_TASK_ROOT` is in the wrong place should be told that, not made to wait on
an unrelated probe that is about to succeed anyway.
"""
home_directory()
if task_root is not None:
_refuse_a_path_inside_a_denied_tree(
f"{WORKSPACE_ROOT_HINT}", _resolved(task_root),
[_resolved(home_directory()), _resolved(paths.grid_home())], SystemExit)
_prove_the_sandbox_can_bind_its_sockets()


Expand Down Expand Up @@ -367,6 +379,54 @@ def _read_rule(path: str) -> str:
return f"Read(/{path}/**)"


def _denied_ancestor(path: str, denied: list[str]) -> str | None:
"""The denied tree `path` sits inside, or `None`. Both sides must already be `_resolved`.

Equality counts: a workspace that IS a denied directory is inside it for every purpose here.
"""
target = Path(path)
for tree in denied:
parent = Path(tree)
if target == parent or parent in target.parents:
return tree
return None


def _refuse_a_path_inside_a_denied_tree(what: str, path: str, denied: list[str], raise_as):
"""ND-01 — the two layers disagree about a path under a denied tree, and only one says so.

`policy` builds two controls over the same paths. The sandbox's `filesystem` block has
`denyRead` **and** `allowRead`, and there the allow really does win: a workspace under a denied
`$HOME` stays readable, and `tests/e2e_agent_sandbox.py` measured that against the real binary.
The `permissions` block has **only** `deny`, because Claude Code's permission layer has no allow
that beats one — so the same workspace is covered by `Read(//$HOME/**)` with nothing to
re-allow it.

MEASURED 2026-08-20 on Claude Code 2.1.234, two arms, same prompt and same project shape: with
the root inside `$HOME` the `Write` tool returns `is_error: true` and the agent says so in its
own words (*"blocked by a Read deny rule in your permission settings"*), then works around it
with a shell redirect — 5 turns, 24 s. With the root outside, `Write` succeeds first time —
3 turns, 15 s. **The task reports `completed` either way**, so the whole cost is invisible: a
less capable agent fails a task that should have run, and nothing anywhere says why.

⚠️ **Refused rather than repaired, because the repair is not available.** The obvious fix —
drop the ancestor from `denyRead` to make room for the workspace — is only sound for the sandbox
layer. In the `permissions` layer it would hand the in-process `Read` tool the operator's whole
home directory, which is the hole that layer exists to close (measured: without it, a run with
the entire sandbox above still read a file outside the workspace). So the configuration is
refused, loudly, at the two places that can see it.
"""
inside = _denied_ancestor(path, denied)
if inside is None:
return
raise raise_as(
f"{what} {path} is inside {inside}, which the agent sandbox denies. The sandbox itself "
f"would re-allow the workspace, but the permission layer that governs the Write tool has "
f"no allow that beats a deny — so the agent would silently lose Write, work around it, and "
f"still report success. Point {WORKSPACE_ROOT_HINT} at a directory outside {inside} "
f"(for example /Users/Shared/gnd on macOS, or /var/grid on Linux).")


_WARNED_ABOUT: set[str] = set()


Expand Down Expand Up @@ -418,6 +478,10 @@ def policy(workspace: Path, config_dir: Path) -> dict:
_warn_if_the_path_is_long_enough_to_break_exec(workspace_path)
home = home_directory()
denied = [_resolved(home), _resolved(paths.grid_home()), _resolved(config_dir)]
# ⚠️ ND-01. The invariant is checked HERE, where the two lists are built and where the
# disagreement between them lives — the argument `task_agent._safe_segment` makes for validating
# a path at the point it is constructed rather than at each caller.
_refuse_a_path_inside_a_denied_tree("the task workspace", workspace_path, denied, ValueError)
# The workspace is re-allowed explicitly, and it matters on a dev box: `GRID_TASK_ROOT` may sit
# under the home directory that was just denied, and `allowRead` is what takes precedence.
allowed = [workspace_path]
Expand Down
Loading
Loading