From 6b6eb7255169138322a891bc8d2fe0a2783033a7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 13 Oct 2026 22:05:12 -0400 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=94=92=20One=20document=20filesystem,?= =?UTF-8?q?=20and=20one=20policy=20on=20it=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated fragment used to reach a filesystem nobody else could see. A host handed its own provider methods to the evaluation profile, capture read each one off once, and core closed a fragment-facing body over the bound result — so an admitted `` performed an operation no `API.Files` middleware observed, no document-scoped policy could refuse, and no provider the run installed answered. Its working directory was a cursor the evaluator owned, because the contextual one is exactly what a document can rebind. That is gone. An admitted name now runs canonical core's own ordinary component, and what remains of the entry is admission: which names, which spelling of each, which requests an admitted `` may perform, and the execution's lifetime. `FragmentEvaluationInput.files` and `.fetch`, `FragmentFileAccess`, `FragmentFetchAccess`, the duplicated read/write/delete/ensure/glob bodies and the directory cursor are all deleted; the adapter that survives does admission, request classification and the lifetime guard, and carries the component's own form dispatcher so canonical selection still enters the body the scan chose. The question that replaces them is `Permissions`, a new runtime Api with two members — `files("read" | "write")` and `fetch(input, init?)` — both answering true until something says otherwise. A scope that permits less installs ordinary middleware returning `false` for what it refuses and delegating the rest, which is what makes restrictions intersect rather than compete. It is asked by the implementation that performs the operation, immediately before its first I/O: the host Files handler, the `API.Fetch` terminal, and each live Workflow performer. Three consequences are the contract. A request is authorized *after* the middleware that may transform it, so what is permitted is what would actually be sent. A denied operation touches nothing — nothing resolved, classified, traversed or created, and a write reports an unchanged target. And in a workflow run the question belongs to the live performer, so a completed effect replays without being re-decided and only new work meets current policy. ``'s information phase needed a boundary, not an exception. The writer frame refuses every capability it can reach, and an admitted read is now an ordinary component arriving at that refusal. So the authenticated phase opens a private read grant bound to its own invocation body scope, a Plan-local `api.Scope.create` observer follows the grant onto the scopes the engine hosts the projection in, and the frame's guard delegates `checkFilePath`, `readTextFile` and `globFiles` for those scopes alone — through its own `next`, so the provider, the middleware and the effective Permissions still decide the read. Everything else stays refused: every mutation, every read outside a live projection, a concurrent sibling of the frame, and a nested frame's grant offered to an enclosing frame's guard. `ensureDirectory` joins that refusal table, which omitted it while nothing in the frame could reach one. Six premises the move untrued, none weakened: - `FE13` asserted that a provider installed around the document is *not* what a fragment reads. It is now, deliberately — and the old row would have passed vacuously, since an inner `min` provider shadows an outer one whatever the fragment reaches. It asserts one middleware seeing both payloads and the transformation deciding what the provider performs. - `FE2` counted the producer's reads and the fragment's separately; both cross one chain now, so the row reads them in order. - `FE5` and `EP17` refused a file entry that stated no operations. There is nothing left to state, so both assert the entry runs the owning component and carries its dispatcher. - `FE29` asserted that nothing a document controls reads, replaces or widens the profile. Answering the operation is now the document's to do; the row says so, and a second row keeps the half that still holds — it cannot admit a name. - `EP23` refused two spellings that bind different results; the owner check catches the same mistake earlier, so it reads "two different components". `core/tests/support/fragment-files.ts` is a real `API.Files` provider now, installed where a host installs one, answering valid `FilesError` data and a validated `FileWriteSuccess` — and asking `Permissions`, because a provider that performs the operation authorizes it. --- architecture.md | 73 +- packages/cli/src/evaluation-profile.ts | 53 +- packages/cli/src/plan-access.ts | 185 ++++ packages/cli/src/plan-component.ts | 61 +- packages/cli/src/plan-writer-profile.ts | 65 +- packages/cli/tests/plan-access.test.ts | 152 +++ .../cli/tests/plan-command-document.test.ts | 61 +- packages/cli/tests/plan-component.test.ts | 195 +++- packages/cli/tests/plan.test.ts | 18 + packages/core/host.ts | 30 +- packages/core/src/components/Evaluate.ts | 12 +- packages/core/src/components/Fetch.ts | 10 +- packages/core/src/evaluation-profile.ts | 69 +- packages/core/src/fragment-capabilities.ts | 910 ++++++------------ .../core/tests/evaluate-component.test.ts | 232 +++-- .../core/tests/evaluate-loaded-copy.test.ts | 6 +- .../tests/evaluate-provider-lifetime.test.ts | 5 +- .../core/tests/evaluation-profile.test.ts | 51 +- .../core/tests/generated-composition.test.ts | 17 +- .../shared-operation-permissions.test.ts | 603 ++++++++++++ packages/core/tests/support/fragment-files.ts | 188 +++- packages/runtime/api.ts | 9 + packages/runtime/apis.ts | 11 + packages/runtime/files.ts | 16 + packages/runtime/host-files.ts | 51 + packages/runtime/mod.ts | 6 +- packages/runtime/permissions.ts | 107 ++ packages/runtime/tests/fetch.test.ts | 91 ++ packages/runtime/tests/host-files.test.ts | 184 ++++ packages/runtime/tests/permissions.test.ts | 134 +++ .../workflow/src/deno/workspace/evaluate.ts | 128 +-- packages/workflow/src/deno/workspace/files.ts | 32 + .../workflow/tests/workspace-files.test.ts | 186 +++- specs/executable-mdx-spec.md | 166 +++- specs/plan-command-spec.md | 29 + specs/workflow-workspace-spec.md | 36 +- 36 files changed, 3122 insertions(+), 1060 deletions(-) create mode 100644 packages/cli/src/plan-access.ts create mode 100644 packages/cli/tests/plan-access.test.ts create mode 100644 packages/core/tests/shared-operation-permissions.test.ts create mode 100644 packages/runtime/permissions.ts create mode 100644 packages/runtime/tests/permissions.test.ts diff --git a/architecture.md b/architecture.md index e3731dc7d..4958ea450 100644 --- a/architecture.md +++ b/architecture.md @@ -2183,24 +2183,29 @@ durable name would each replay the other's admitted fragment, and an implementation running under another attachment's identity would commit against its own run's storage under that attachment's expansion. -An admitted element reaches the operations the host handed the profile, and no -others. It used to invoke the ordinary component — core's `` — which -resolves `API.Files` at the moment it runs, so the permission a fragment -exercised was whatever the document, a repository component or middleware had -installed by then. A profile now states a *capability* rather than a definition: -canonical capture reads each of the host's methods off once, binds it behind a -revocation the execution owns, and closes core's own body over the result. So a -fragment reaches the captured filesystem operations, including glob search, -and one transport — not the rest of -the host's provider, not `API.Files`, `API.Fetch` or `API.Env`, and not for -longer than the execution that captured them. A directory an admitted fragment -creates scopes its content through the evaluation's own cursor rather than the -contextual environment, because the contextual environment is exactly what a -document can answer. - -That narrowing changed what these identities authorize, so core's entries state -revision 2 and the bundled Git Plugin's `` states revision 3. **A run suspended -under the earlier revisions refuses to resume rather than silently receiving the +An admitted element runs canonical core's own ordinary component, and the +operation it performs is the ordinary one. For a while it did not: a profile +stated a *capability* whose body core closed over operations the host handed it, +so a generated read reached a filesystem nothing in the document's scope could +observe, transform or refuse — and a generated `` scoped its content +through a cursor the evaluator owned rather than through the contextual +environment. Both are gone. A fragment's operations now cross contextual +`API.Files` and `API.Fetch` like every other caller's, which means the middleware +an authored read crosses, the provider the host installed, and the effective +`Permissions` of the consuming scope all apply to them. + +What the profile states is admission: which names, which spelling of each, which +requests an admitted `` may perform, and the execution's lifetime — an +admitted implementation refuses once the execution that captured it has ended. +A host hands the profile no operation at all, because there is no second +filesystem for it to hand. What a fragment may *do* is then the ordinary +question asked of any caller: the provider that is installed, and the policy of +the scope the element runs in. + +The narrowing these revisions record is the one that still stands — an entry +authorizes one component under one spelling — so core's entries state revision 2 +and the bundled Git Plugin's `` states revision 3. **A run suspended under +the earlier revisions refuses to resume rather than silently receiving the narrower grant**, which is what a revision is for: a continuation resumes only under the exact identities it was admitted with. @@ -4622,7 +4627,35 @@ middleware must reject an incompatible retained history supplies an explicit `@executablemd/core/api` publishes exactly the consumer surface: `Plugin`, the three contextual APIs and their direct operations, and the contract types the accepted interfaces are written in. `@executablemd/runtime/api` publishes the -existing `Config` Api a Plugin reads timeouts and verbosity through. +existing `Config` Api a Plugin reads timeouts and verbosity through, and +`Permissions`, which a scope installs middleware around to permit less than its +host does: + +```ts +import { Permissions } from "@executablemd/runtime/api"; + +yield* Permissions.around({ + files: ([operation], next) => (operation === "write" ? false : next(operation)), +}); +``` + +Two members, `files("read" | "write")` and `fetch(input, init?)`, both answering +`true` until something says otherwise. A restriction returns `false` to refuse +and otherwise delegates the unchanged arguments, which is what makes +restrictions intersect: the answer is the whole chain's, so a scope nested +inside a read-only region cannot permit a write by answering `true`. Returning +`true` early is the one thing a restriction must not do, because it answers for +every enclosing scope as well. A decision is synchronous — there is nothing to +wait for in reading a policy the installing scope already holds, and the answer +stands immediately before an effect. + +The question is asked by the implementation that performs the operation, +immediately before its first I/O: the `API.Files` provider for the six semantic +filesystem operations and the `API.Fetch` terminal for a request. That is after +every contextual transformation, so what is permitted is what would actually be +performed rather than what was first asked for. It mediates those semantic +operations and nothing by implication — `API.Fs`, subprocesses and Git host +operations are not covered, and a `true` answer supplies no capability. What a *host* does stays on `./host`. `Markdown({…})`, `sourceDigest` and `Structural({…})` construct what is declared to an execution, and @@ -5460,7 +5493,7 @@ Status is measured against main. | history fork | creates a new run from one compatible checkpoint and retained Workspace root, under a new immutable definition and normalized props | built on the #368 stack, Deno provider only | | workflow Agent session | a workflow document's `` runs under a profile the host attaches only for a live or partial run: an empty host-owned working directory instead of any Workspace, checkout or caller path, no MCP servers, an empty requested native tool set, and `deny-all` with a permission path that denies every native request and fails the turn that asked without reaching the public permission chain. Within a run a session is identified by the Agent/Session expansion identity the engine derived — the authored name is descriptive, so two sibling `` elements are two sessions — routed inside a placement bound to its element and good for one use, so a kept placement cannot be substituted for the next. The conversation is retained as a row in the run's own database with the provider, resolved agent command and policy fingerprint beside it as compatibility attributes. The order is placement, the backend's acceptance of the session's first turn, the provider's canonical tagged assertion, then the mapping commit — and only then is anything that turn produced exposed. A placement is inert: it creates no provider session and writes no row. Occupancy of a provider key is not an assertion, and a record held for a first turn nobody accepted asserts nothing at all; the pre-commit window reconciles only from exactly one canonical assertion, and a missing, conflicting, replaced or ambiguous assertion is one explicit refusal that starts no replacement. Deleting a run removes the row with the run and the provider-session directory beside it, and reports the categories. The profile selects ACP-only capability explicitly — no native-launch advertisement and no client-native attachment advertisement — rather than inheriting the provider package's ordinary-run sets by omission, and it supplies no machine session coordinator, construction-route store or executable observer: a workflow session belongs to a run, and the machine-wide account describes a different thing entirely | built on the #302 stack, with the explicit ACP-only selection from #561; the portable proof that an adapter honours an empty tool set is tracked by #496 and does not widen the ceiling | | generated-XMD admission | admits one Agent-generated fragment through the trusted-host seam: host policy is a `read` table and a `write` table of exact pinned identities, each carrying the authored forms it is admitted for, and an authored `allow` selects a canonical non-empty subset of the closed classes — omitted means `read`. The complete source is preflighted inside one `generated_xmd` durable effect before its first generated effect; only the pinned identity the selected classes hold for that name **and** that form executes; and the admitted source, class selection, selected root, every selected entry with its forms, the identity and form of each element named, and the normalized request policy are retained in that effect's own result — so a continuation restores the decision without reading the current candidate and expands only the retained source. The roots are an as-of-admission retained basis checked by membership — the run's own later root publications and an advanced retained current root pass, while a lost admission root or lost selected root refuses — and every non-root term is checked exactly, refusing a run whose classes, identities, forms or requests have moved. The admission and every nested generated effect are offered inline by the owning expansion in authored order, so a partial continuation restores each completed one without another live execution. Each admitted effect is retained by its own ordinary record, and every component keeps its ordinary binding and output behavior | built on the #369 stack, continuation basis amended by #589; core owns the mechanics and the workflow policy wrapper is internal | -| `` | canonical core's own protected component, written where program text the document did not author should run. Core claims the name ahead of every host and author tier, so no registration, repository file, bundle member, declared Markdown component, import handler answer or second loaded copy replaces it; a handler may observe or refuse the import, and only canonical execution answers one. Protection settles which implementation runs and grants nothing: a host supplies the *ceiling* as one `ExecutionInstallation.evaluation` profile, captured by value before any installation runs, and an execution accepts one and refuses two. Its schema is closed on `text` and an optional `allow` array selecting a non-empty duplicate-free subset of the closed effect classes `read` and `write` — omitted means `read`. The two input forms are disjoint: `text` states the program, paired content renders it, and an element stating both is refused rather than resolved by precedence. A paired producer renders under the narrowed syntax reference through an execution-owned one-shot projection that bypasses the public `content()`/`tryContent()` chain, and keeps its own operational permission while doing so. Evaluate renders the fragment's ordinary output and invents no observation or result envelope. Fragment-local `as` bindings suppress their component's output normally, and language constructs plus pure components such as `` remain available regardless of effect selection; the fragment explicitly renders any bound values it wants to expose. Every ceiling comes from values the host captured at installation — the run's retained roots and its authoritative current root read from the run's own storage per invocation, as-of-admission provenance a continuation holds by membership so the run's own later publications and an advanced retained current root invalidate nothing, core's self-closing `` read — joined in the ordinary run profile by core's self-closing `` and canonical protected `` — the write table of core's paired ``, core's `` under the identity the host that assembles the profile states, since this package states no directory entry of its own — and core's self-closing ``, and `` only when the captured request ceiling is non-empty — and no prop, binding, context or middleware return value supplies or widens one. An entry states what is behind a name in one of exactly two ways, and neither carries a function. A *capability* names an operation core supplies the body for: canonical capture reads the host's own operation off once, binds it behind a revocation the execution owns, and closes core's own body over it, so an admitted element reaches those operations and never `API.Files`, `API.Fetch` or `API.Env`. A capability states what it binds wherever the ordinary component does, so an admitted `` is the value component the ordinary one is — sharing its props, return contract, source rules and sanitized failure sentence, refusing a missing `as` and an unusable pattern before the search, and reaching the captured search operation and captured working directory rather than a provider a document arranged. A name admitted for two spellings that would bind different results refuses at capture. A *component answer* names an implementation the ordinary import chain resolves, and states only the identity a provider must have claimed for it; canonical execution resolves that name once — before the root import and before any document code, through the complete ordinary `Component.importComponent` chain and a private terminal that writes no record — and asks the identity owner about the exact final answer in one call. That call answers with the claim *and* core's own copy of what was claimed, taken when the claim was recorded: the check and the thing kept are one result, so nothing downstream reads the chain's object a second time and an answer whose members read differently on each read cannot pass a check with one reading and be sealed with another. The identity is then compared whole, and the copy — not the answer — is sealed behind the same revocation. One name states one identity, however many forms and tables hold it: two entries under one name are the two spellings of one component and share one lookup and one sealed implementation, while a second identity for that name, or a name held as both a capability and a component answer, refuses at capture rather than letting assembly order decide. Provider installation and the right to answer one occurrence are separate: installation receives a registrar that installs import middleware, and every invocation of that middleware receives a fresh request fixed to the exact name, position, provider installation, origin and resolution-window object it was asked under. Only that request may claim, and it states the answer plus key and revision without restating the name. Canonical execution closes the request synchronously when its handler returns, fails or is cancelled; an outer request remains live while it delegates and may claim a replacement after the inner handler returns. Enclosing resolution still closes in its own `finally` on success, fallback, failure or cancellation. A claim therefore requires the execution, request and exact current window to remain active, the request's fixed name to match that window, and that provider installation not to have stated a different answer in the window. Identification takes the expected window explicitly and accepts only a claim recorded for that exact object and name, so a stale request cannot answer a later same-name resolution or retag itself while another name is live. The installation is reusable: one provider may answer several admitted names and repeated resolutions through distinct requests. All of this state is held in ordinary private closures — no Context, shared symbol, public brand or module-global registry — and an unidentified middleware replacement remains a valid ordinary import answer outside fragment evaluation. A capability entry also states the exact version-1 identity strings it succeeds, which is the only thing a released untagged record reconciles against; a component answer states none. A capability-only profile performs no such lookup at all. Resolution happens at capture and never again: a fragment runs the sealed snapshot, a continuation resolves once more in its own capture and reconciles before any effect, and a provider still answering when a fragment resolves its admitted name is answering a generated import, which only canonical execution answers. A trusted layering control may enter one package bootstrap through an inherited layer and an execution-local one — documentation, registrations and the provider together — and the repeat is additive: the outer entry observes and delegates rather than claiming a second identity for an implementation that already states what it is, and a non-identical documentation overlap on one owner and component still refuses at the child collection boundary, before the root import and therefore before the provider is asked at all. `allow` selects among those tables and adds nothing to them; approval, when a workflow needs one, is authored control flow before the element. Its durable operation is named through that claimant, on the exact invocation the engine handed it and in that invocation's own frame — not from a context a document could rebind, a contextual Api answer, a definition, or a registry answer. Generated effects use occurrence-owned identities in the existing ordinary durable sequence: their records persist and replay normally, with no staging or rollback. The generated projection and its structured teardown complete before Evaluate returns and later parent work begins. It is deliberately not wrapped in `printErrors`, so a refused fragment stops the authored loop rather than becoming text the next turn could read as a read that happened | built on the #302 stack, extended by #369 | +| `` | canonical core's own protected component, written where program text the document did not author should run. Core claims the name ahead of every host and author tier, so no registration, repository file, bundle member, declared Markdown component, import handler answer or second loaded copy replaces it; a handler may observe or refuse the import, and only canonical execution answers one. Protection settles which implementation runs and grants nothing: a host supplies the *ceiling* as one `ExecutionInstallation.evaluation` profile, captured by value before any installation runs, and an execution accepts one and refuses two. Its schema is closed on `text` and an optional `allow` array selecting a non-empty duplicate-free subset of the closed effect classes `read` and `write` — omitted means `read`. The two input forms are disjoint: `text` states the program, paired content renders it, and an element stating both is refused rather than resolved by precedence. A paired producer renders under the narrowed syntax reference through an execution-owned one-shot projection that bypasses the public `content()`/`tryContent()` chain, and keeps its own operational permission while doing so. Evaluate renders the fragment's ordinary output and invents no observation or result envelope. Fragment-local `as` bindings suppress their component's output normally, and language constructs plus pure components such as `` remain available regardless of effect selection; the fragment explicitly renders any bound values it wants to expose. Every ceiling comes from values the host captured at installation — the run's retained roots and its authoritative current root read from the run's own storage per invocation, as-of-admission provenance a continuation holds by membership so the run's own later publications and an advanced retained current root invalidate nothing, core's self-closing `` read — joined in the ordinary run profile by core's self-closing `` and canonical protected `` — the write table of core's paired ``, core's `` under the identity the host that assembles the profile states, since this package states no directory entry of its own — and core's self-closing ``, and `` only when the captured request ceiling is non-empty — and no prop, binding, context or middleware return value supplies or widens one. An entry states what is behind a name in one of exactly two ways, and neither carries a function. A *capability* names the component canonical core supplies for it: capture answers with core's own ordinary definition — the same ``, ``, ``, ``, `` or `` an author writes — held to the execution's lifetime, with the component's own form dispatcher travelling beside it so canonical selection enters the body the scan chose. The operation it performs is therefore the ordinary one, through contextual `API.Files` and `API.Fetch`, under the provider the host installed and the effective `Permissions` of the consuming scope; the host hands the profile no operation of its own. A capability states what it binds wherever the ordinary component does because it *is* the ordinary component, so an admitted `` is the value component the ordinary one is — sharing its props, return contract, source rules and sanitized failure sentence, refusing a missing `as` and an unusable pattern before the search, and resolving its paths against the contextual working directory. One name runs one component, so a name admitted for capabilities owned by two different components refuses at capture. An admitted `` is the one entry with a ceiling: the element is classified against the entry's exact admitted requests, performs the admitted one rather than its props, and that ceiling is installed as an ordinary `Permissions` restriction for the invocation, so a contextual transformation that moves the request off it is refused by the terminal rather than sent. A *component answer* names an implementation the ordinary import chain resolves, and states only the identity a provider must have claimed for it; canonical execution resolves that name once — before the root import and before any document code, through the complete ordinary `Component.importComponent` chain and a private terminal that writes no record — and asks the identity owner about the exact final answer in one call. That call answers with the claim *and* core's own copy of what was claimed, taken when the claim was recorded: the check and the thing kept are one result, so nothing downstream reads the chain's object a second time and an answer whose members read differently on each read cannot pass a check with one reading and be sealed with another. The identity is then compared whole, and the copy — not the answer — is sealed behind the same revocation. One name states one identity, however many forms and tables hold it: two entries under one name are the two spellings of one component and share one lookup and one sealed implementation, while a second identity for that name, or a name held as both a capability and a component answer, refuses at capture rather than letting assembly order decide. Provider installation and the right to answer one occurrence are separate: installation receives a registrar that installs import middleware, and every invocation of that middleware receives a fresh request fixed to the exact name, position, provider installation, origin and resolution-window object it was asked under. Only that request may claim, and it states the answer plus key and revision without restating the name. Canonical execution closes the request synchronously when its handler returns, fails or is cancelled; an outer request remains live while it delegates and may claim a replacement after the inner handler returns. Enclosing resolution still closes in its own `finally` on success, fallback, failure or cancellation. A claim therefore requires the execution, request and exact current window to remain active, the request's fixed name to match that window, and that provider installation not to have stated a different answer in the window. Identification takes the expected window explicitly and accepts only a claim recorded for that exact object and name, so a stale request cannot answer a later same-name resolution or retag itself while another name is live. The installation is reusable: one provider may answer several admitted names and repeated resolutions through distinct requests. All of this state is held in ordinary private closures — no Context, shared symbol, public brand or module-global registry — and an unidentified middleware replacement remains a valid ordinary import answer outside fragment evaluation. A capability entry also states the exact version-1 identity strings it succeeds, which is the only thing a released untagged record reconciles against; a component answer states none. A capability-only profile performs no such lookup at all. Resolution happens at capture and never again: a fragment runs the sealed snapshot, a continuation resolves once more in its own capture and reconciles before any effect, and a provider still answering when a fragment resolves its admitted name is answering a generated import, which only canonical execution answers. A trusted layering control may enter one package bootstrap through an inherited layer and an execution-local one — documentation, registrations and the provider together — and the repeat is additive: the outer entry observes and delegates rather than claiming a second identity for an implementation that already states what it is, and a non-identical documentation overlap on one owner and component still refuses at the child collection boundary, before the root import and therefore before the provider is asked at all. `allow` selects among those tables and adds nothing to them; approval, when a workflow needs one, is authored control flow before the element. Its durable operation is named through that claimant, on the exact invocation the engine handed it and in that invocation's own frame — not from a context a document could rebind, a contextual Api answer, a definition, or a registry answer. Generated effects use occurrence-owned identities in the existing ordinary durable sequence: their records persist and replay normally, with no staging or rollback. The generated projection and its structured teardown complete before Evaluate returns and later parent work begins. It is deliberately not wrapped in `printErrors`, so a refused fragment stops the authored loop rather than becoming text the next turn could read as a read that happened | built on the #302 stack, extended by #369 | | generated mutation proposals | lets an Agent propose constrained executable changes that a separate admission then performs against the run's own Workspace | built on the #369 and #567 stacks, with directory creation added by #643: the standard Deno profile's write table is core's paired `File:write`, the paired `@executablemd/workflow/composition/dir-v2#Dir` and core's self-closing `File.Delete`, in that retained order and followed by any host extension. `allow={["write"]}` intentionally authorizes Dir's persistent recursive directory creation; its versioned identity makes every continuation retained under the former non-mutating Dir identity refuse before generated execution. Admitted mutations run as the ordinary components they are through the run's effect transactions, and the evaluator adds no mutation API or receipt. Approval is authored control flow before the write-enabled element. Local Git, Git-host, issue, process, execution, credential and external-write effects are outside the class | | Deno-local DOFS provider | owns one authoritative SQLite/DOFS connection per run path, captures arbitrary canonical retained roots, privately restores them, and atomically coordinates one Workspace mutation with its filtered Yield | built on the #365 stack; public document filesystem effects and the CLI lifecycle route to it on the #366 stack | | scoped Worker Shell | executes `just-bash` through the Workspace adapter inside a Deno Worker | containment and effect-transaction POCs complete (#351, #357); production integration unbuilt | diff --git a/packages/cli/src/evaluation-profile.ts b/packages/cli/src/evaluation-profile.ts index deadf5cb0..fe96a11cf 100644 --- a/packages/cli/src/evaluation-profile.ts +++ b/packages/cli/src/evaluation-profile.ts @@ -20,6 +20,16 @@ * Elicitation provider the run already has. It reaches nothing else at all: no * network read, no process, no repository, no Git, no credential and no agent. * + * ## It states no filesystem of its own + * + * An admitted name runs canonical core's own component, which performs its + * operation through contextual `API.Files` — the provider this command's + * entrypoint installed, and the same one every authored `` in the + * document crosses. So a fragment's read is observed, transformed, delegated + * and refused by exactly the middleware an author's read is, and the effective + * `Permissions` of the scope it runs in decide it. This file hands the profile + * no operation at all, because there is no second filesystem for it to hand. + * * Reading and writing stay separate spellings of one name. A selection of * `read` admits `` and not `…`, so a fragment that asks to * search and read cannot also write, and `` describing a component @@ -52,54 +62,13 @@ import { globReadEntry, syntaxReadEntry, } from "@executablemd/core/host"; -import type { - ExecutionInstallation, - FragmentEvaluationInput, - FragmentFileAccess, -} from "@executablemd/core/host"; -import { cwd, hostFilesHandler } from "@executablemd/runtime"; - -/** - * The exact filesystem operations an admitted fragment performs. - * - * A handler of this command's own, constructed here rather than read from - * `API.Files` when a fragment runs. The document's provider and this one behave - * identically — both are `hostFilesHandler()` — and the difference is that - * nothing a document, a repository component or middleware installs is between - * a fragment and this instance. - * - * Only the six operations a fragment can name are copied across. The handler - * also offers temporary directories; an admitted fragment has none, and not - * copying it is what makes that true. - */ -function ordinaryFiles(): FragmentFileAccess { - const handler = hostFilesHandler(); - return { - checkFilePath: (input) => handler.checkFilePath(input), - readTextFile: (input) => handler.readTextFile(input), - globFiles: (input) => - handler.globFiles({ - cwd: input.cwd, - include: [...input.include], - exclude: [...input.exclude], - }), - writeTextFile: (input) => handler.writeTextFile(input), - deleteFile: (input) => handler.deleteFile(input), - ensureDirectory: (input) => handler.ensureDirectory(input), - // The caller's working directory, which is what `xmd run` resolves every - // authored path against. Read when a fragment runs rather than frozen at - // assembly, so a fragment and the document that produced it resolve the - // same relative path to the same file. - workingDirectory: () => cwd(), - }; -} +import type { ExecutionInstallation, FragmentEvaluationInput } from "@executablemd/core/host"; /** The ceiling `xmd run` and its run children state. */ export function ordinaryEvaluationProfile(): FragmentEvaluationInput { return { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], write: [fileWriteEntry(), fileDeleteEntry(), elicitWriteEntry()], - files: ordinaryFiles(), }; } diff --git a/packages/cli/src/plan-access.ts b/packages/cli/src/plan-access.ts new file mode 100644 index 000000000..9b678c248 --- /dev/null +++ b/packages/cli/src/plan-access.ts @@ -0,0 +1,185 @@ +/** + * Which scopes inside a Plan writer frame may read the document filesystem. + * + * The writer frame refuses every capability it can reach: a Plan decides what + * to write, and writing a file, running a command, starting a service and + * reaching the network are all the approved document's business. One phase of + * the Component is the exception. A `` projection runs public + * `` under the host's own ceiling so an Agent can ask what a file + * holds before proposing a Plan — and that read now travels the ordinary Files + * route, where the frame's ambient refusal is sitting. + * + * So the refusal needs to tell one thing from another: a read performed inside + * a live information projection, and every other read in the frame. Both arrive + * at the same guard, through the same Api, with the same arguments. What + * separates them is **where** they are asked from, and that is what this + * module keeps track of. + * + * ## Why scopes, and not a context + * + * A context is reachable to anything that can spell its name, and a flag in one + * would be a permission a document could set. Private `WeakMap`s keyed by the + * real `Scope` objects are reachable to nothing but this module: a scope cannot + * be forged, cannot be named and cannot be handed over, so a holder either *is* + * the scope the grant was opened for or is a descendant of it. + * + * Descendants are the whole difficulty. The engine hosts an invocation's + * content in a scope it creates lazily from the invocation's body scope, and + * every element inside the fragment runs in further children of that. Nothing + * walks a scope's ancestry from outside, so membership is established as the + * tree is built: a Plan-local observer on `api.Scope` sees each child as it is + * created, delegates the creation unchanged, and records the child under + * whatever its parent held. That covers the hosted content, `scoped()` children + * and spawned tasks alike, because all of them create their scope through that + * one API. + * + * ## What a grant is, and is not + * + * It is not a capability. The guard it unlocks *delegates* — the enclosing and + * descendant Files middleware keep their ordinary turn, the installed provider + * still performs the operation, and the effective `Permissions` of the scope + * asking still decide it. A grant says only "this read is the information + * phase's, so do not refuse it here". + * + * It is also not a mode. Every mutation stays refused inside the projection, + * and every read stays refused outside it — a writer sibling created while a + * projection is open inherits the writer record and no grant, and a nested + * writer frame has a record of its own that another frame's grant does not + * match. Nothing here is process-wide, and nothing survives the projection: the + * grant is revoked in the projection's own `finally`, and the record with the + * writer frame it belongs to. + */ + +import { ensure, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import { api } from "effection/experimental"; + +/** One writer frame's private bookkeeping. */ +interface WriterRecord { + live: boolean; +} + +/** One live information projection, belonging to one writer frame. */ +interface ReadGrant { + live: boolean; + readonly record: WriterRecord; +} + +/** What the writer frame's capability refusal asks before it refuses a read. */ +export interface PlanWriterAccess { + /** + * Whether the scope asking is inside a live information projection of *this* + * writer frame. + * + * Read at the call, from the consuming scope, so a read performed after the + * projection settled — or in a sibling that never held a grant — is answered + * no with nothing to reset. + */ + reading(): Operation; +} + +/** The private access bookkeeping one `` declaration owns. */ +export interface PlanAccess { + /** + * Begin the writer frame, in the authenticated writer invocation's own scope. + * + * Registers its own revocation before the record exists, so a frame that is + * torn down — or interrupted while being built — leaves nothing a later read + * could match. + */ + openWriter(): Operation; + /** + * Open a read grant for this information projection, and answer with how to + * close it. + * + * Called in the authenticated `` body, before its first + * projection, so the scope it binds is the one the engine hosts that + * projection's content from. A wrapper scope opened here instead would be a + * sibling of the hosted content rather than its parent, and the content would + * inherit nothing. + */ + openInformation(): Operation<() => void>; +} + +/** + * One declaration's access bookkeeping. + * + * Built where the `` declaration is built, and its closures handed to the + * two private factories — so the maps are shared by exactly the phases of one + * Component and reachable from nothing else. Not a package export, not a + * context, and not a general facility: an implementation detail of this + * Component with the lifetime of the declaration that holds it. + */ +export function createPlanAccess(): PlanAccess { + const records = new WeakMap(); + const grants = new WeakMap(); + + /** + * Follow the tree as it grows. + * + * The delegation is unconditional and the result is returned exactly as it + * came back: this observes scope creation and changes nothing about it. A + * child is recorded under its parent's record, and under its parent's grant + * when the parent is inside a live projection — which is how the hosted + * content of an information body, and everything that content creates, come + * to be covered without anything walking ancestry later. + */ + function observe(scope: Scope): void { + scope.around(api.Scope, { + create([parent], next) { + const created = next(parent); + const child = created[0]; + const record = records.get(parent); + if (record !== undefined) { + records.set(child, record); + } + const grant = grants.get(parent); + if (grant !== undefined) { + grants.set(child, grant); + } + return created; + }, + }); + } + + return { + *openWriter(): Operation { + const scope = yield* useScope(); + const record: WriterRecord = { live: true }; + // Registered before the record is reachable, so an interruption between + // the two leaves no record behind rather than one nothing revokes. + yield* ensure(() => { + record.live = false; + }); + records.set(scope, record); + observe(scope); + return { + *reading(): Operation { + const asking = yield* useScope(); + const grant = grants.get(asking); + // All three, and the record identity is the one that is easy to + // forget: a nested writer frame's projection holds a live grant, and + // it is not this frame's to honour. + return grant !== undefined && grant.live && grant.record.live && grant.record === record; + }, + }; + }, + *openInformation(): Operation<() => void> { + // The body's own scope, which is the parent the engine creates this + // invocation's hosted content from. + const scope = yield* useScope(); + const record = records.get(scope); + if (record === undefined) { + // An information phase outside any writer frame of this declaration. + // Nothing to grant and nothing to refuse differently: the projection + // runs under whatever the enclosing scope already permits. + return () => {}; + } + const grant: ReadGrant = { live: true, record }; + grants.set(scope, grant); + return () => { + grant.live = false; + }; + }, + }; +} diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 8cc93f395..6652f1eb2 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -75,6 +75,8 @@ import type { import type { DocumentValidation } from "@executablemd/core"; import type { ComponentInvocation } from "@executablemd/core"; +import { createPlanAccess } from "./plan-access.ts"; +import type { PlanAccess } from "./plan-access.ts"; import { DEFAULT_PLAN_WRITER_ROOT, installPlanWriterFrame, @@ -400,6 +402,9 @@ export function* planComponentDeclaration( const validate = assembly.validate ?? structuralValidation(assembly.includes, declared, assembly.plugins ?? NO_PLUGINS); + // The private access bookkeeping these phases share, built here so the two + // factories below close over one set of maps and nothing else can reach them. + const access = createPlanAccess(); const declaration = Markdown({ name: PLAN_COMPONENT, origin: PLAN_ORIGIN, @@ -415,12 +420,12 @@ export function* planComponentDeclaration( exact: true, privates: [ planInputs(assembly), - planWriter(assembly), + planWriter(assembly, access), planProgress(assembly), checkDraft(validate), admitPlan(validate), classifyPlanResponseComponent(), - planInformation(), + planInformation(access), ], }); declared.push(declaration); @@ -629,14 +634,24 @@ function placementFor( * Component — the structural admission and the return — is therefore written after * teardown by construction rather than by a rule somebody has to remember. */ -function planWriter(assembly: PlanComponentAssembly): IdentityComponent { +function planWriter(assembly: PlanComponentAssembly, access: PlanAccess): IdentityComponent { return { name: "PlanWriter", origin: `${PLAN_ORIGIN}#PlanWriter`, forms: ["paired"], props: PLAN_WRITER_PROPS, - factory: () => - function* PlanWriter(props: Record): Operation { + factory: (claim: IdentityClaimant) => + function* PlanWriter( + props: Record, + invocation: ComponentInvocation, + ): Operation { + // The identity canonical execution minted where resolution selected + // this exact implementation, for this exact expansion. Asked before the + // frame's access bookkeeping is opened, because what opens it has to be + // this invocation rather than a name, a copied function or a caller's + // claim that it is one. + yield* claim(invocation); + const writerAccess = yield* access.openWriter(); // Before a directory exists, before a provider exists, and therefore // before any session could be placed or any turn started. A host that // supplies no Agent context refuses rather than writing a Plan under @@ -666,6 +681,7 @@ function planWriter(assembly: PlanComponentAssembly): IdentityComponent { planWriter: context.value, host: assembly.host, session, + access: writerAccess, ...(typeof props.authoredSession === "string" ? { authoredSession: props.authoredSession } : {}), @@ -788,6 +804,22 @@ function classifyPlanResponseComponent(): IdentityComponent { }; } +/** + * Run `body`, and close the read grant however it settles. + * + * The grant has to outlive the projection *and* its joined teardown, and it has + * to be gone before the settled text is classified or scanned — which is + * exactly a `finally` around the one call, written here so the phase below + * reads as the sequence it is. + */ +function* holding(close: () => void, body: () => Operation): Operation { + try { + return yield* body(); + } finally { + close(); + } +} + /** * Run one information request, and say how it settled. * @@ -823,19 +855,30 @@ function classifyPlanResponseComponent(): IdentityComponent { * disclosed. Canonical execution owns the policy and the scanner; this asks * them and appends nothing. */ -function planInformation(): IdentityComponent { +function planInformation(access: PlanAccess): IdentityComponent { return { name: "PlanInformation", origin: `${PLAN_ORIGIN}#PlanInformation`, forms: ["paired"], props: EMPTY_PROPS, returns: INFORMATION_RETURNS, - factory: () => - function* PlanInformation(): Operation { + factory: (claim: IdentityClaimant) => + function* PlanInformation( + _props: Record, + invocation: ComponentInvocation, + ): Operation { + // Authenticated before anything is granted, for the reason the writer + // phase is: a read grant anything shaped like this could open would be + // a read the writer frame does not get to refuse. + yield* claim(invocation); + // Bound to this body's own scope, which is the parent the engine hosts + // the projection's content from — held open across the projection and + // its joined teardown, and revoked here however it settled. + const close = yield* access.openInformation(); // The child and its structured teardown have both settled by the time // this returns, so what is scanned below is complete text and not a // fragment still able to produce more. - const projected = yield* tryContent(); + const projected = yield* holding(close, () => tryContent()); const settled = ((): { status: string; text: string } => { if (projected.failure === undefined) { return { status: "found", text: projected.text }; diff --git a/packages/cli/src/plan-writer-profile.ts b/packages/cli/src/plan-writer-profile.ts index 69790579e..625b0b199 100644 --- a/packages/cli/src/plan-writer-profile.ts +++ b/packages/cli/src/plan-writer-profile.ts @@ -54,6 +54,7 @@ import { API } from "@executablemd/runtime"; import { FormOpener } from "@executablemd/web"; import { hostAcpDependencies } from "./agent-stack.ts"; +import type { PlanWriterAccess } from "./plan-access.ts"; import type { PlanWriterStack } from "./agent-stack.ts"; import { ordinaryEvaluationProfile } from "./evaluation-profile.ts"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; @@ -330,6 +331,15 @@ export interface PlanWriterFrame { readonly session: string; /** The exact authored label a trusted child host may address privately. */ readonly authoredSession?: string; + /** + * What this frame's capability refusal asks before it refuses a read. + * + * The authenticated information phase of the Component holds a grant for the + * scope its projection runs in, and this is how the ambient refusal below + * recognizes one. A frame installed without it refuses every read, which is + * what a surface with no information phase wants. + */ + readonly access?: PlanWriterAccess; observe?(reference: PlanWriterObservation): Operation; installElicitation(): Operation; } @@ -370,7 +380,7 @@ export function* installPlanWriterFrame(frame: PlanWriterFrame): Operation policy: PLAN_WRITER_POLICY, }); yield* installPlanPromptFailurePolicy(); - yield* refuseDocumentCapabilities(); + yield* refuseDocumentCapabilities(frame.access); // After everything, and it is the installation rather than an account of one. // Whether the prompt-failure policy is installed is not reported here at all: // a value saying so would agree with itself however the middleware behaved, @@ -777,21 +787,43 @@ function* releaseSessionDirectory(directory: string, claim: DirectoryClaim): Ope * refused rather than served. It decides what to write; writing a file, * running a command, starting a service and reaching the network are all the * approved document's business, under the caller's own configuration. + * + * ## The one exception, and how it is recognized + * + * An authenticated `` projection runs public `` + * under the host's own ceiling, so an Agent can ask what a file holds before + * proposing a Plan. That read performs the ordinary `` or `` + * component, so it arrives here like any other — and `access.reading()` is + * what tells the two apart: it answers for the scope the call was made from, + * and only a scope inside a live projection of *this* writer frame is granted. + * + * What the exception does is **delegate**, through this middleware's own + * `next`. The enclosing and descendant Files middleware keep their ordinary + * turn, the installed provider still performs the operation, and the effective + * `Permissions` of the consuming scope still decide it. Nothing here answers a + * permission with true, and nothing runs the request in a wider scope. + * + * Reads only. Every mutation stays refused wherever it is asked from — a + * projection that wrote a file would be an unaudited fragment changing the + * caller's filesystem under a frame whose whole purpose is that it cannot — + * and so does every read outside a projection. */ -function* refuseDocumentCapabilities(): Operation { +function* refuseDocumentCapabilities(access: PlanWriterAccess | undefined): Operation { const refuse = (capability: string) => () => { throw new Error( `xmd plan asked for ${capability}, which the Plan writer profile grants to nothing`, ); }; + /** Whether this call is the information phase's own read. */ + function* granted(): Operation { + return access === undefined ? false : yield* access.reading(); + } yield* API.Files.around({ - // deno-lint-ignore require-yield - *checkFilePath() { - return refuse("a file")(); + *checkFilePath([input], next) { + return (yield* granted()) ? yield* next(input) : refuse("a file")(); }, - // deno-lint-ignore require-yield - *readTextFile() { - return refuse("a file")(); + *readTextFile([input], next) { + return (yield* granted()) ? yield* next(input) : refuse("a file")(); }, // deno-lint-ignore require-yield *writeTextFile() { @@ -801,9 +833,22 @@ function* refuseDocumentCapabilities(): Operation { *deleteFile() { return refuse("a file")(); }, + /** + * Refused, and newly named. + * + * The table omitted the semantic directory operation while nothing in the + * writer frame could reach one: `` was a Plugin's component and the + * frame declared none. Canonical core owns it now, and an admitted + * generated `` performs it through this same Api — so the accepted + * no-mutation boundary has to say so rather than rely on the name being + * unreachable. + */ // deno-lint-ignore require-yield - *globFiles() { - return refuse("a file")(); + *ensureDirectory() { + return refuse("a directory")(); + }, + *globFiles([input], next) { + return (yield* granted()) ? yield* next(input) : refuse("a file")(); }, // deno-lint-ignore require-yield *temporaryDirectory() { diff --git a/packages/cli/tests/plan-access.test.ts b/packages/cli/tests/plan-access.test.ts new file mode 100644 index 000000000..9a47f27b9 --- /dev/null +++ b/packages/cli/tests/plan-access.test.ts @@ -0,0 +1,152 @@ +/** + * Tier PLA — which scopes a Plan writer frame grants a read to. + * + * The integration rows live in the Plan suites: a request crossing the shared + * Files chain, a restriction denying it, the writer frame refusing everything + * ambient. What those cannot show cheaply is the *shape* of the grant — a + * sibling created while a projection is open, a nested frame's grant offered to + * an outer frame's guard, a grant asked about after it closed. Each of those is + * one scope relationship, and this drives them directly. + * + * Driving the mechanism rather than a document is the point. The guard asks one + * question — "is the scope asking inside a live projection of my frame?" — and + * every way of answering it wrongly is a way of handing an unaudited fragment, + * or the writer frame itself, a read it was not given. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, spawn, withResolvers } from "effection"; + +import { createPlanAccess } from "../src/plan-access.ts"; +import type { PlanWriterAccess } from "../src/plan-access.ts"; + +describe("Tier PLA — the read grant inside a Plan writer frame", () => { + it("PLA1: a writer frame grants nothing until a projection opens", function* () { + const access = createPlanAccess(); + yield* scoped(function* () { + const writer = yield* access.openWriter(); + + // The frame's own scope, and a child of it: neither is a projection. + expect(yield* writer.reading()).toBe(false); + yield* scoped(function* () { + expect(yield* writer.reading()).toBe(false); + }); + }); + }); + + it("PLA2: a projection grants its own scope and everything created under it", function* () { + const access = createPlanAccess(); + yield* scoped(function* () { + const writer = yield* access.openWriter(); + + const close = yield* scoped(function* () { + const held = yield* access.openInformation(); + expect(yield* writer.reading()).toBe(true); + // A child created after the grant opened — which is what the engine's + // hosted content, and everything the fragment runs, actually are. + yield* scoped(function* () { + expect(yield* writer.reading()).toBe(true); + // And a child of that child, because a fragment nests. + yield* scoped(function* () { + expect(yield* writer.reading()).toBe(true); + }); + }); + return held; + }); + + // Revoked, and the answer is no from the frame again. The projection's + // own scope has ended too, so this is the frame asking. + close(); + expect(yield* writer.reading()).toBe(false); + }); + }); + + it("PLA3: a sibling created while a projection is open is granted nothing", function* () { + const access = createPlanAccess(); + const opened = withResolvers(); + const observed: boolean[] = []; + + yield* scoped(function* () { + const writer = yield* access.openWriter(); + + // The concurrent writer sibling: a task of the frame's own, running + // while the projection below is suspended mid-read. + const sibling = yield* spawn(function* () { + yield* opened.operation; + observed.push(yield* writer.reading()); + }); + + yield* scoped(function* () { + yield* access.openInformation(); + expect(yield* writer.reading()).toBe(true); + // Opened, and held open: the sibling asks while this is live rather + // than after it settled, which is the only ordering that could be + // mistaken for a grant. + opened.resolve(); + yield* sibling; + }); + }); + + // The sibling inherited the writer record — it is in the frame — and no + // read grant, because its parent held none when it was created. + expect(observed).toEqual([false]); + }); + + it("PLA4: one frame's projection does not satisfy another frame's guard", function* () { + const access = createPlanAccess(); + yield* scoped(function* () { + const outer = yield* access.openWriter(); + + yield* scoped(function* () { + // A second writer frame, nested inside the first. Its projection is + // perfectly good — for it. + const inner = yield* access.openWriter(); + yield* scoped(function* () { + yield* access.openInformation(); + expect(yield* inner.reading()).toBe(true); + // The outer frame's guard is asked about the same scope and answers + // no: a grant belongs to the frame that opened it, and a nested + // conversation cannot read on an enclosing one's behalf. + expect(yield* outer.reading()).toBe(false); + }); + }); + }); + }); + + it("PLA5: a retained guard answers no once its frame has ended", function* () { + const access = createPlanAccess(); + let retained: PlanWriterAccess | undefined; + let granted: boolean | undefined; + + yield* scoped(function* () { + yield* scoped(function* () { + retained = yield* access.openWriter(); + const close = yield* access.openInformation(); + expect(yield* retained.reading()).toBe(true); + close(); + }); + // The frame is gone. Whatever held its guard is holding something that + // answers for a conversation that has ended. + granted = retained === undefined ? undefined : yield* retained.reading(); + }); + + expect(granted).toBe(false); + }); + + it("PLA6: an information phase outside any frame of this declaration grants nothing", function* () { + const access = createPlanAccess(); + yield* scoped(function* () { + // No writer frame was opened, so there is no record to bind a grant to. + // The projection runs under whatever the enclosing scope permits, which + // is the honest answer: there is no frame here to refuse it. + const close = yield* access.openInformation(); + close(); + // Nothing to assert about a guard, because there is no guard. What this + // row establishes is that opening the phase is not itself a grant: a + // frame opened afterwards sees no projection. + const writer = yield* access.openWriter(); + expect(yield* writer.reading()).toBe(false); + }); + }); +}); diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 5adc3b8fc..aba7e9852 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -53,7 +53,8 @@ import type { import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; import { elicitWriteEntry } from "@executablemd/core/host"; import { Elicitation } from "@executablemd/core"; -import { recordedFiles } from "../../core/tests/support/fragment-files.ts"; +import { recordedFiles, useRecordedFiles } from "../../core/tests/support/fragment-files.ts"; +import type { RecordedFiles } from "../../core/tests/support/fragment-files.ts"; import { answerProvider } from "../../core/tests/support/answer-provider.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; @@ -150,10 +151,19 @@ interface RunOptions { /** * The evaluation ceiling this case's information requests run under. * - * The command's own by default. A case supplies its own when it needs to see - * which operation a read reached, or to withhold one. + * The command's own by default. A case supplies its own when it needs to + * narrow what a request may name, or to withhold a class. */ evaluation?: FragmentEvaluationInput; + /** + * The document filesystem this execution runs against. + * + * Installed as the ordinary `API.Files` provider, which is what an admitted + * information request now crosses: a row reading a recorder's log is reading + * the same provider an authored `` in this document would have reached. + * Absent leaves the real host provider in place. + */ + files?: RecordedFiles; /** * The vocabulary this execution describes. * @@ -233,6 +243,9 @@ function* runDocument(options: RunOptions = {}): Operation { // A case that supplies its own recorder gets that instead, which is // how a row proves which operation a read actually reached. evaluation: options.evaluation ?? ordinaryEvaluationProfile(), + ...(options.files === undefined + ? {} + : { install: () => useRecordedFiles(options.files!) }), ...(options.componentAnswers === undefined ? {} : { componentAnswers: [...options.componentAnswers] }), @@ -765,7 +778,8 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(run.failure).toBe(undefined); @@ -795,7 +809,8 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, verbose: true, }); @@ -816,7 +831,8 @@ describe("Tier PI — read-only information requests", () => { // A paired `` write under a read-only selection: refused whole, // before any read. turns: [{ reply: 'written\n' }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(run.failure).toBe(undefined); @@ -842,7 +858,8 @@ describe("Tier PI — read-only information requests", () => { // The ninth would search a second time if it were evaluated, so the // recorder answers "was it?" rather than a count standing in for it. turns: [...turns, { reply: '\n' }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(String(run.failure)).toContain("asked for information 8 times"); @@ -876,7 +893,8 @@ describe("Tier PI — read-only information requests", () => { ], reviews: [{ decision: "Request changes", feedback: "say more" }, { decision: "Approve" }], validations: [unsound, sound], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(run.failure).toBe(undefined); @@ -972,7 +990,8 @@ describe("Tier PI — read-only information requests", () => { { reply: '\n\n' }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(run.failure).toBe(undefined); @@ -996,7 +1015,8 @@ describe("Tier PI — read-only information requests", () => { }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(run.failure).toBe(undefined); @@ -1009,7 +1029,8 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, verbose: true, }); @@ -1035,7 +1056,8 @@ describe("Tier PI — read-only information requests", () => { { reply: '\n\n' }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, verbose: true, }); @@ -1147,10 +1169,8 @@ describe("Tier PI — read-only information requests", () => { { reply: '\n\n\n' }, { reply: CANDIDATE }, ], - evaluation: { - read: [fileReadEntry(), globReadEntry(), syntaxReadEntry(), heldEntry()], - files, - }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry(), heldEntry()] }, + files, componentAnswers: [held.provider], verbose: true, observe: function* (live) { @@ -1208,7 +1228,8 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: '\n' }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, verbose: true, }); @@ -1227,7 +1248,8 @@ describe("Tier PI — read-only information requests", () => { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ turns: [{ reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(run.failure).toBe(undefined); @@ -1284,7 +1306,8 @@ describe("Tier PI — the ordinary ceiling admits the question with the write", { reply: 'Which file?\n' }, { reply: CANDIDATE }, ], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); }); diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index c5d16f3a9..1fdcdad14 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -40,7 +40,8 @@ import { syntaxReadEntry, } from "@executablemd/core/host"; import type { FragmentEvaluationInput } from "@executablemd/core/host"; -import { recordedFiles } from "../../core/tests/support/fragment-files.ts"; +import { API, Permissions } from "@executablemd/runtime"; +import { recordedFiles, useRecordedFiles } from "../../core/tests/support/fragment-files.ts"; import type { RecordedFiles } from "../../core/tests/support/fragment-files.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; @@ -118,6 +119,22 @@ function* runDocument(options: { * is what makes "this execution offers no evaluation" the default. */ evaluation?: FragmentEvaluationInput; + /** + * The document filesystem this execution runs against. + * + * Installed as the ordinary `API.Files` provider, which is what an admitted + * information request crosses: a row reading a recorder's log is reading the + * same provider an authored `` in this document would have reached. + */ + files?: RecordedFiles; + /** + * Anything else this case installs inside the execution. + * + * Runs after the recorder, so middleware installed here sits between the + * document and that provider — which is where a document's own handler, and a + * region's own policy, go. + */ + installing?: () => Operation; /** * Install the output middleware an ordinary `xmd run` installs. * @@ -181,6 +198,18 @@ function* runDocument(options: { // component, and what it answers with is this execution's. symbols: harness.symbols, ...(options.evaluation === undefined ? {} : { evaluation: options.evaluation }), + ...(options.files === undefined && options.installing === undefined + ? {} + : { + *install(): Operation { + if (options.files !== undefined) { + yield* useRecordedFiles(options.files); + } + if (options.installing !== undefined) { + yield* options.installing(); + } + }, + }), }, ], ); @@ -475,8 +504,8 @@ describe("Tier PC — in an ordinary document", () => { "\n", ); - function reading(files: RecordedFiles): FragmentEvaluationInput { - return { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }; + function reading(): FragmentEvaluationInput { + return { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }; } /** A harness whose agent answers a request first and a Plan second. */ @@ -498,7 +527,8 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(files), + evaluation: reading(), + files: files, }); expect(run.failure).toBe(undefined); @@ -508,6 +538,108 @@ describe("Tier PC — in an ordinary document", () => { }); }); + /** + * The route an information request takes, observed from both sides of it. + * + * This is the row the shared route is worth having: the request leaves the + * authenticated projection, crosses the middleware the document installed + * *and* the middleware installed nearer the provider, and what the findings + * carry is what the provider was asked for after both of them had their + * turn. Before U2 none of this was reachable — the fragment performed an + * operation the host handed the profile, which no handler in the document's + * scope could observe, transform or refuse. + */ + it("PI20: enclosing and descendant middleware both see the request, and the transform decides it", function* () { + yield* useWorkingDirectory(function* () { + // The file the request asks for does not exist; the one the transform + // substitutes does. So a finding naming it can only have come from the + // transformed request. + const files = recordedFiles({ "renamed.md": "the renamed note\n" }); + const enclosing: string[] = []; + const run = yield* runDocument({ + source: SOURCE, + harness: yield* asking(yield* planWriterRoot()), + reviews: [], + evaluation: reading(), + files: files, + *installing(): Operation { + // Nearer the provider, and transforming: installed after the + // recorder, so it is the last handler the request crosses. + yield* API.Files.around( + { + *globFiles([input], next) { + return yield* next({ ...input, include: ["renamed.md"] }); + }, + }, + { at: "min" }, + ); + // Where a document's own handler goes, and observing only. + yield* API.Files.around({ + *globFiles([input], next) { + enclosing.push(`glob ${input.include.join(",")}`); + return yield* next(input); + }, + }); + }, + }); + + expect(run.failure).toBe(undefined); + // What the document's handler saw is what the fragment asked for. + expect(enclosing).toEqual(["glob notes.md"]); + // What the provider performed is what the nearer handler substituted. + expect(files.performed).toEqual(["glob renamed.md"]); + // And the findings the next turn was given carry that answer. + expect(run.harness.fake.prompts[1] ?? "").toContain("renamed.md"); + expect(run.output).toContain(`got: ${PLAN}`); + }); + }); + + /** + * And the effective policy of the scope it runs in decides it. + * + * The projection delegates through the writer's guard; it does not answer + * `Permissions` with true, and it does not run the request anywhere wider. + * So a read restriction the enclosing scope installed is in force inside + * the projection — and the refusal it produces is an ordinary one, which + * this workflow turns into exactly one more turn. + * + * The ordering half of this claim — that a descendant answering `true` + * cannot defeat an enclosing `false` — is Tier PM's, proved there against + * the Api itself rather than duplicated here as another permutation. + */ + it("PI21: a read restriction denies the request and becomes one safe retry", function* () { + yield* useWorkingDirectory(function* () { + const files = recordedFiles({ "notes.md": "the retained note\n" }); + const run = yield* runDocument({ + source: SOURCE, + harness: yield* asking(yield* planWriterRoot()), + reviews: [], + evaluation: reading(), + files: files, + *installing(): Operation { + yield* Permissions.around({ + files: ([operation], next) => (operation === "read" ? false : next(operation)), + }); + }, + }); + + // Authorship finished: the refusal was feedback rather than a failure. + expect(run.failure).toBe(undefined); + expect(run.output).toContain(`got: ${PLAN}`); + // Denied before the provider performed anything. + expect(files.performed).toEqual([]); + // One more turn, carrying a sentence about the refusal and nothing from + // a platform: the next prompt is what the Agent was given. + expect(run.harness.fake.prompts).toHaveLength(2); + const retry = run.harness.fake.prompts[1] ?? ""; + expect(retry).toContain("That request was refused"); + // The sanitized sentence, in the words an author would be told, and + // carrying nothing a platform or a provider wrote. + expect(retry).toContain("cannot search the working directory: permission denied."); + expect(retry).not.toContain("/"); + }); + }); + it("PI12: an embedded request emits no command progress into the document", function* () { yield* useWorkingDirectory(function* () { const files = recordedFiles({ "notes.md": "the retained note\n" }); @@ -515,7 +647,8 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(files), + evaluation: reading(), + files: files, // The presentation an ordinary `xmd run` installs, so a phase written // into the document would arrive here exactly as an operator would // read it rather than being normalized away. @@ -563,7 +696,8 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness, reviews: [], - evaluation: reading(files), + evaluation: reading(), + files: files, }); expect(run.failure).toBe(undefined); @@ -580,7 +714,8 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(files), + evaluation: reading(), + files: files, stream: first, }); expect(one.failure).toBe(undefined); @@ -593,7 +728,8 @@ describe("Tier PC — in an ordinary document", () => { const two = yield* runDocument({ source: SOURCE, reviews: [], - evaluation: reading(tripwire), + evaluation: reading(), + files: tripwire, stream: first, }); @@ -613,7 +749,8 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness: yield* asking(yield* planWriterRoot()), reviews: [], - evaluation: reading(files), + evaluation: reading(), + files: files, stream: first, }); expect(one.failure).toBe(undefined); @@ -626,7 +763,8 @@ describe("Tier PC — in an ordinary document", () => { const two = yield* runDocument({ source: SOURCE, reviews: [], - evaluation: reading(resumed), + evaluation: reading(), + files: resumed, stream: yield* continuing(first), }); @@ -661,7 +799,8 @@ describe("Tier PC — in an ordinary document", () => { source: SOURCE, harness, reviews: [], - evaluation: reading(files), + evaluation: reading(), + files: files, stream: first, }); expect(one.failure).toBe(undefined); @@ -676,7 +815,8 @@ describe("Tier PC — in an ordinary document", () => { const two = yield* runDocument({ source: SOURCE, reviews: [], - evaluation: reading(recordedFiles({ "notes.md": "the retained note\n" })), + evaluation: reading(), + files: recordedFiles({ "notes.md": "the retained note\n" }), stream: yield* continuing(first), harness: yield* planDeclarationHarness({ surface: "component", @@ -727,7 +867,8 @@ describe("Tier PC — in an ordinary document", () => { root, harness: yield* asking(root), reviews: [], - evaluation: reading(files), + evaluation: reading(), + files: files, stream: first, }); expect(one.failure).toBe(undefined); @@ -742,7 +883,8 @@ describe("Tier PC — in an ordinary document", () => { props: { request: "Write something else." }, root, reviews: [], - evaluation: reading(changed), + evaluation: reading(), + files: changed, stream: yield* continuing(first), }); @@ -1573,8 +1715,8 @@ describe("Tier PC — in an ordinary document", () => { */ describe("Tier FE — Plan produces text, Evaluate runs it", () => { /** A profile admitting one read, over a recorder these rows can count. */ - function profile(files: RecordedFiles): FragmentEvaluationInput { - return { read: [fileReadEntry()], files }; + function profile(): FragmentEvaluationInput { + return { read: [fileReadEntry()] }; } /** @@ -1619,7 +1761,7 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { * inside an evaluation names `test://fe713/read` and the admitted capability * is core's file read behind it. */ - function fixtureProfile(files: RecordedFiles): FragmentEvaluationInput { + function fixtureProfile(): FragmentEvaluationInput { return { read: [ { @@ -1629,7 +1771,6 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { description: READ_COMPONENT.description, }, ], - files, }; } @@ -1689,7 +1830,8 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { "", ].join("\n"), reply: PROGRAM, - evaluation: profile(files), + evaluation: profile(), + files: files, }); expect(run.failure).toBe(undefined); @@ -1709,7 +1851,8 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { const run = yield* runDocument({ source: ['Write a program.', "", "done", ""].join("\n"), reply: `# Read the note\n\n\n`, - evaluation: profile(files), + evaluation: profile(), + files: files, }); expect(run.failure).toBe(undefined); @@ -1738,7 +1881,8 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { "", ].join("\n"), reply: `# Read the note\n\n\n`, - evaluation: fixtureProfile(files), + evaluation: fixtureProfile(), + files: files, }); }); @@ -1789,7 +1933,8 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { // The agent writes the component it was *not* told about. It resolves // at the ordinary site and is not in the profile. reply: `# Use the wide component\n\n\n`, - evaluation: fixtureProfile(files), + evaluation: fixtureProfile(), + files: files, }); }); @@ -1820,7 +1965,8 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { // the component only the authored site has. Titled, so it is a draft // the review approves rather than an information request. reply: `# Use the wide component\n\n\n\n`, - evaluation: fixtureProfile(files), + evaluation: fixtureProfile(), + files: files, }); }); @@ -1874,7 +2020,8 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { // information request — and it still carries the executable fence the // later, narrower `` must refuse. reply: '# Read and run\n\n\n\n```bash exec\nprintf ran\n```\n', - evaluation: profile(files), + evaluation: profile(), + files: files, }); expect(run.failure).not.toBe(undefined); diff --git a/packages/cli/tests/plan.test.ts b/packages/cli/tests/plan.test.ts index 47d969852..a8dd5e5cd 100644 --- a/packages/cli/tests/plan.test.ts +++ b/packages/cli/tests/plan.test.ts @@ -744,6 +744,22 @@ describe( // its scope. It decides what to write; it writes nothing. for (const [name, ask] of [ ["a directory", () => API.Files.operations.temporaryDirectory()], + // Ambient, and therefore refused: a read is delegated only + // inside an authenticated information projection, and this is + // the writer frame asking for itself. Asked here because a + // read is the one operation the frame now has a reason to + // delegate at all. + [ + "a file", + () => API.Files.operations.readTextFile({ cwd: ".", path: "notes.md" }), + ], + // The semantic directory operation, named in the table rather + // than left unreachable: canonical core owns `` now, and + // an admitted generated one performs this. + [ + "a directory", + () => API.Files.operations.ensureDirectory({ cwd: ".", path: "made" }), + ], ["a command", () => API.Process.operations.exec({ command: ["true"] })], ["the network", () => API.Fetch.operations.fetch("http://localhost")], ] as const) { @@ -775,6 +791,8 @@ describe( expect(harness.fake.ensured[0]?.sessionOptions?.allowedTools).toEqual([]); // And the command document is given nothing to act with. expect(seen.refusals).toEqual([ + "xmd plan asked for a directory, which the Plan writer profile grants to nothing", + "xmd plan asked for a file, which the Plan writer profile grants to nothing", "xmd plan asked for a directory, which the Plan writer profile grants to nothing", "xmd plan asked for a command, which the Plan writer profile grants to nothing", "xmd plan asked for the network, which the Plan writer profile grants to nothing", diff --git a/packages/core/host.ts b/packages/core/host.ts index bc38d08b7..f044081e3 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -120,27 +120,21 @@ export type { ComponentAnswerRequest, } from "./src/component-answers.ts"; /** - * The private operations an admitted fragment performs, and the only ones it - * can reach. - * - * A host hands its own provider methods here; canonical capture reads each one - * off once and closes core's own fragment bodies over the bound result. There - * is no ordinary-core export for these, no getter for a live one, and no way to - * install one from a document — see `src/fragment-capabilities.ts`. + * Which component an admitted entry runs. + * + * A name a host admits, and nothing about how the operation behind it is + * performed: an admitted entry runs canonical core's own ordinary component, + * through whichever `API.Files` or `API.Fetch` provider is installed where the + * fragment runs — see `src/fragment-capabilities.ts`. A host that evaluates + * against its own filesystem installs that provider; it hands no operation to + * the profile. */ -export type { - FragmentCapability, - FragmentFetchAccess, - FragmentFileAccess, - FragmentPath, - FragmentSearch, - FragmentWrite, -} from "./src/fragment-capabilities.ts"; +export type { FragmentCapability } from "./src/fragment-capabilities.ts"; /** - * The response shape a fragment transport answers with, and the detaching a - * host needs to build one. + * The response shape a retained request answers with, and the detaching a host + * needs to read one. * - * The same record an authored `` retains, through the same code, so a + * The record an authored `` retains, through the same code, so a * fragment's observation and a document's are the same shape and a continuation * restores either. */ diff --git a/packages/core/src/components/Evaluate.ts b/packages/core/src/components/Evaluate.ts index 7e3b4e333..87f33b49e 100644 --- a/packages/core/src/components/Evaluate.ts +++ b/packages/core/src/components/Evaluate.ts @@ -239,11 +239,12 @@ function evaluate(claim: IdentityClaimant): ProtectedBody { ...(basis === undefined ? {} : { workspaceRoots: basis.roots, selectedRoot: basis.current }), ...(expansion.position === undefined ? {} : { position: expansion.position }), }; - // Every path in this fragment resolves against the directory the run is in - // now, and the cursor is restored however the evaluation ends — so a - // fragment produced inside another fragment's producer leaves the outer one - // where it was, and a failed one leaves nothing behind. - const leave = yield* profile.enterFragment(); + // Where this fragment's relative paths resolve is the contextual working + // directory, exactly as it is for an authored element: an admitted name + // runs the ordinary component, so `` inside the fragment installs a + // directory for its own content and the invocation that owns it takes it + // away again. There is no cursor of the evaluator's own to enter or + // restore. const narrowedBodies = site.narrowComponentRouting( entries.admitted.map((entry) => entry.definition.fn), ); @@ -251,7 +252,6 @@ function evaluate(claim: IdentityClaimant): ProtectedBody { return yield* evaluateProtectedGeneratedXmd(request, narrowedBodies, narrowedSyntax); } finally { narrowedBodies?.close(); - leave(); } }; } diff --git a/packages/core/src/components/Fetch.ts b/packages/core/src/components/Fetch.ts index f4911f42c..5c44a4d4f 100644 --- a/packages/core/src/components/Fetch.ts +++ b/packages/core/src/components/Fetch.ts @@ -78,8 +78,14 @@ function isSuccess(status: number): boolean { * not depend on a provider that invalidates its own header collection once the * body has been consumed. A `HEAD` never calls `text()`: there is no body to * read, and asking for one would fail against a provider that says so. + * + * Exported for the one other caller that performs an authored request: an + * admitted generated `` performs the request its entry's ceiling + * admitted rather than the props it was written with, so it cannot enter this + * component through its props — and a second observation of its own would be a + * second answer to what a retained response is. */ -function* observe(request: FetchRequest): Operation { +export function* observeFetch(request: FetchRequest): Operation { const response = yield* fetch(request.url, { method: request.method, headers: request.headers, @@ -105,7 +111,7 @@ export default function* Fetch(props: Record): Operation { ...(expansion.position === undefined ? {} : { position: expansion.position }), }, request, - () => observe(request), + () => observeFetch(request), ); const response = parseResponseRecord(retained); diff --git a/packages/core/src/evaluation-profile.ts b/packages/core/src/evaluation-profile.ts index 11dd6c183..ff9949d72 100644 --- a/packages/core/src/evaluation-profile.ts +++ b/packages/core/src/evaluation-profile.ts @@ -42,17 +42,16 @@ import { CORE_ORIGIN, ELICIT_COMPONENT } from "./components/registry.ts"; import { SYNTAX_COMPONENT } from "./components/Syntax.ts"; import { CAPABILITY_FORMS, - capabilityDefinition, + capabilityImplementation, capabilityProps, captureCapabilities, FragmentCapabilityError, REVOKED_CAPABILITY, } from "./fragment-capabilities.ts"; import type { + CapabilityImplementation, CapturedCapabilities, FragmentCapability, - FragmentFetchAccess, - FragmentFileAccess, } from "./fragment-capabilities.ts"; import { isFormDispatcher } from "./invocation-identity.ts"; import type { ComponentInvocation, ComponentRouting } from "./invocation-identity.ts"; @@ -235,17 +234,6 @@ export interface FragmentEvaluationInput { readonly write?: readonly FragmentEntry[]; /** How this host answers for its Workspace, when it evaluates against one. */ readonly workspace?: FragmentWorkspaceAccess; - /** - * The exact filesystem operations the admitted entries run. - * - * Required by every file and directory entry, and read off this object once - * at capture. A profile that admits `` without stating them is a - * profile that admitted an operation it cannot perform, and is refused rather - * than falling through to whatever provider a document installed. - */ - readonly files?: FragmentFileAccess; - /** The exact transport an admitted `` performs its request through. */ - readonly fetch?: FragmentFetchAccess; /** * The effective Fetch timeout this host resolved, in milliseconds. * @@ -563,16 +551,7 @@ export interface EvaluationProfile { readonly read: readonly CapturedEntry[]; readonly write: readonly CapturedEntry[]; readonly workspace?: FragmentWorkspaceAccess; - /** - * Begin one fragment, and answer with how to end it. - * - * Reads the host's working directory once per fragment rather than once per - * element, and restores whatever was current afterwards — so a fragment - * produced inside another fragment's producer leaves the outer one where it - * was. - */ - readonly enterFragment: () => Operation<() => void>; - /** Whether this profile's bound operations are still usable. */ + /** Whether this profile's admitted implementations are still usable. */ readonly live: () => boolean; /** * End them. @@ -625,6 +604,8 @@ interface PreparedEntry { readonly props?: PropsSchema; readonly capability?: FragmentCapability; readonly definition?: FunctionComponentDefinition; + /** The owning component's own form dispatcher, when its implementation wraps one. */ + readonly dispatch?: unknown; readonly legacy?: readonly string[]; readonly requests?: readonly FetchRequest[]; } @@ -674,13 +655,11 @@ export interface PreparedProfile { export function* prepareEvaluationProfile( input: FragmentEvaluationInput, ): Operation { - // Every live operation is read off the host's objects here, once, before a - // single installation has run. What comes back is bound and revocable, and - // the host's own objects are never consulted again. - const capabilities = captureCapabilities({ - ...(input.files === undefined ? {} : { files: input.files }), - ...(input.fetch === undefined ? {} : { fetch: input.fetch }), - }); + // The lifetime every admitted implementation is held to, opened here before a + // single installation has run. There is no host operation to read off an + // object any more: an admitted name runs canonical core's own component, + // through the provider installed where the fragment runs. + const capabilities = captureCapabilities(); // One definition per *name*, built from every capability that name holds // across both tables. `` observing and `…` writing are // two entries under one name, and the evaluator resolves an import by name — @@ -738,7 +717,6 @@ export function* prepareEvaluationProfile( read: sealEntries(read, sealed), write: sealEntries(write, sealed), ...(workspace === undefined ? {} : { workspace }), - enterFragment: () => capabilities.enterFragment(), live: () => capabilities.live(), revoke: () => { capabilities.revoke(); @@ -817,7 +795,7 @@ function bindWorkspace(access: FragmentWorkspaceAccess): FragmentWorkspaceAccess function buildDefinitions( input: FragmentEvaluationInput, capabilities: CapturedCapabilities, -): Map { +): Map { const admitted = new Map< string, { "self-closing"?: FragmentCapability; paired?: FragmentCapability } @@ -839,11 +817,16 @@ function buildDefinitions( ceilings.set(entry.name, entry.requests); } } - const built = new Map(); + const built = new Map(); for (const [name, forms] of admitted) { built.set( name, - capabilityDefinition(name, forms, capabilities, normalizedCeiling(ceilings.get(name), input)), + capabilityImplementation( + name, + forms, + capabilities, + normalizedCeiling(ceilings.get(name), input), + ), ); } return built; @@ -870,7 +853,7 @@ function normalizedCeiling( function* prepareEntries( entries: readonly FragmentEntry[], fetchTimeout: number | undefined, - built: Map, + built: Map, ): Operation { const prepared: PreparedEntry[] = []; for (const entry of entries) { @@ -882,7 +865,7 @@ function* prepareEntries( function* prepareEntry( entry: FragmentEntry, fetchTimeout: number | undefined, - built: Map, + built: Map, ): Operation { const forms = canonicalForms(entry.forms); if (forms.length === 0) { @@ -917,10 +900,10 @@ function* prepareEntry( // Detached, so a host that edits its schema afterwards does not change what a // fragment's props are validated against. const props = detach(entry.props); - // The definition every entry under this name shares, built before any entry - // was prepared. - const definition = built.get(entry.name); - if (definition === undefined) { + // The implementation every entry under this name shares, built before any + // entry was prepared. + const implementation = built.get(entry.name); + if (implementation === undefined) { throw new EvaluationProfileError( "an evaluation profile admitted a name with no operation behind it.", ); @@ -934,7 +917,8 @@ function* prepareEntry( kind: "capability" as const, capability: entry.capability, ...described, - definition, + definition: implementation.definition, + ...(implementation.dispatch === undefined ? {} : { dispatch: implementation.dispatch }), ...(legacy === undefined ? {} : { legacy }), ...(requests === undefined ? {} : { requests }), }); @@ -1126,6 +1110,7 @@ function sealEntry(entry: PreparedEntry, sealed: ReadonlyMap` used to be core's ordinary `` component, which - * resolves its provider through `API.Files` at the moment it runs. That made - * what a generated fragment may do a property of whatever the - * document, a repository component or middleware had installed by then — so a - * fragment admitted for "read a file" reached whichever Files provider was - * nearest, and a handler composed around one could observe, rewrite or answer - * the call. + * A host states that a generated fragment may read a file, and this is what + * decides *what* answers that name. The answer is canonical core's own ordinary + * component — the same ``, ``, ``, ``, `` + * and `` an author writes — so a generated element and an authored one + * are one implementation performing one operation through one provider. * - * The operations here are the alternative. A trusted host hands its private - * provider to `captureEvaluationProfile()`, which reads each method off it - * exactly once and closes a fragment-facing component over the bound result. - * What runs inside a fragment therefore reaches: + * There is deliberately nothing else here. The operations used to be a private + * transport: a host handed its own filesystem object to the profile, capture + * read each method off it once, and core closed a fragment-facing body over the + * bound result. That made a generated read a second filesystem implementation, + * invisible to the `API.Files` middleware every other read crosses, and it put + * a fragment's working directory in a cursor this module owned rather than in + * the contextual environment the rest of the engine uses. Both are gone. What a + * fragment may do is now mediated where every other caller's operations are + * mediated — by the installed provider, and by `Permissions` at the + * implementation that performs the I/O. * - * - only the methods this file names — read, search, path check, write, delete - * and directory creation, plus one Fetch — and not the rest of the host's - * provider, not `API.Files`, not `API.Fetch`, and not `API.Env`; - * - only the working directory the host stated when it built the profile; and - * - only for as long as the execution that captured them is alive. + * ## What a host still decides * - * ## Why the components are core's own + * Everything about admission, and nothing about performance: * - * A host cannot supply the body, because a body is where a provider lookup - * would go. These are written once, here, against the bound operations, and a - * host chooses only *which* of them a fragment may name and under what - * identity. That is also why the schemas match the ordinary components': a - * fragment written against `` is the same text an author would - * write, and only the operation behind it differs. + * - **which names** a fragment may write, and under which identity a + * continuation compares them; + * - **which spelling** each name is admitted for, so `` observing and + * `…` writing are two grants rather than one — decided in + * preflight, from the scan, before the fragment's first effect; + * - **which requests** an admitted `` may perform, as an exact ceiling + * belonging to that entry; + * - **how long** any of it lasts: every admitted implementation is held to the + * execution that captured it, and refuses once that execution has ended. * - * ## What is deliberately absent + * What it no longer decides is which filesystem or transport the operation + * reaches. That was never a property of the name — it is the installed + * provider's, and a workflow run's transaction-bound provider is as much the + * ordinary route as the host adapter is. * - * No temporary directory, no environment, no process and no agent. Those are - * operations the ordinary components have and an admitted fragment does not, - * and leaving them out here is what makes that true rather than a claim about - * what a host will remember not to admit. + * ## Why the engine still selects the component's own dispatcher * - * Asking a person is here, and is the one capability whose body is not closed - * over a captured operation: `elicit:ask` *is* canonical core's ``, and - * what reaches a person is the Elicitation Api lexically in scope where the - * fragment runs. That is the one contextual thing about it — a host chooses - * whether a fragment may ask at all, and the provider already in place decides - * how. Nothing about which implementation asks is resolved by name. + * `` runs two irreversible bodies, and which one an element gets is + * canonical dispatch's decision, taken from the scan. A dispatcher refuses an + * invocation whose selection is not itself, so an adapter that wrapped one in a + * second dispatcher would be an adapter no form could get through. The guard + * below is therefore a plain wrapper — it opens no scope and selects no body — + * and the component's own dispatcher travels beside it as the entry's + * `dispatch`, where canonical selection records it. */ -import type { Operation, Result } from "effection"; +import { scoped } from "effection"; +import type { Operation } from "effection"; -import type { FunctionComponentDefinition, Json, PropsSchema, ReturnsSchema } from "./types.ts"; -import { content } from "./component-api.ts"; -import { ContentError, ProjectedContentError } from "./errors.ts"; +import type { + FunctionComponent, + FunctionComponentDefinition, + Json, + PropsSchema, + ReturnsSchema, +} from "./types.ts"; import { getExpansion } from "./expansion.ts"; +import { observeFetch } from "./components/Fetch.ts"; import { persistFetch } from "./fetch-journal.ts"; import { parseResponseRecord } from "./fetch-response.ts"; -import type { FetchResponseRecord } from "./fetch-response.ts"; -import Elicit, { props as ELICIT_PROPS, returns as ELICIT_RETURNS } from "./components/Elicit.ts"; -import { GLOB_PROPS, GLOB_RETURNS, globFailure, globPatterns } from "./glob-source.ts"; +import { fixedFilesystemDefinition } from "./components/fixed-filesystem.ts"; +import { coreComponentDefinition, ELICIT_COMPONENT } from "./components/registry.ts"; +import { FileAccessError } from "./components/File.ts"; +import { FileDeleteError } from "./components/FileDelete.ts"; +import { DirInvocationError } from "./components/Dir.ts"; +import { GlobError } from "./components/Glob.ts"; import { markGeneratedRequestRefusal } from "./generated-request-refusal.ts"; -import { formDispatcher } from "./invocation-identity.ts"; -import { parseFilesFailure } from "@executablemd/runtime"; +import { isFormDispatcher } from "./invocation-identity.ts"; +import type { ComponentInvocation } from "./invocation-identity.ts"; +import type { FetchRequest } from "./fetch-request.ts"; +import { Permissions } from "@executablemd/runtime"; -/** A refusal an admitted fragment's own operation produced. */ +/** A refusal an admitted fragment's own request produced. */ export class FragmentCapabilityError extends Error { override name = "FragmentCapabilityError"; } @@ -69,244 +83,33 @@ export class FragmentCapabilityError extends Error { export const REVOKED_CAPABILITY = "an admitted fragment reached an operation belonging to an execution that has ended."; -/** One path an operation is about to act on, resolved against the stated root. */ -export interface FragmentPath { - readonly cwd: string; - readonly path: string; -} - -/** One write an operation is about to perform. */ -export interface FragmentWrite extends FragmentPath { - readonly content: string; -} - -/** - * One search an operation is about to perform, under the stated root. - * - * The patterns rather than a resolved list, because the provider owns matching: - * what an admitted fragment states is the same two prop values an author writes, - * already held to the shared source rules, and the search that answers them is - * the host's. - */ -export interface FragmentSearch { - readonly cwd: string; - readonly include: readonly string[]; - readonly exclude: readonly string[]; -} - -/** - * The exact filesystem operations a fragment may perform. - * - * A strict subset of what a host's own provider offers, listed rather than - * derived: a fragment provider built by widening `FilesHandler` would gain - * every operation a later release adds to it, which is the opposite of what a - * ceiling is for. - * - * `Result` rather than a throw, matching the provider contract the host already - * implements, so a host passes its own methods without adapting them. - */ -export interface FragmentFileAccess { - /** Whether this path is admissible at all. No filesystem access. */ - checkFilePath(input: FragmentPath): Operation>; - readTextFile(input: FragmentPath): Operation>; - /** Sorted, deduplicated, POSIX-separated paths of the regular files that match. */ - globFiles(input: FragmentSearch): Operation>; - writeTextFile(input: FragmentWrite): Operation>; - deleteFile(input: FragmentPath): Operation>; - ensureDirectory(input: FragmentPath): Operation>; - /** - * The directory every path above resolves against. - * - * An operation rather than a value, because a workflow run's authoritative - * root advances as the run commits, and a fragment admitted later addresses - * the root the run is on rather than the one it started from. - */ - workingDirectory(): Operation; -} - -/** - * The exact HTTP read a fragment may perform. - * - * It answers with the same record the ordinary `` retains — status, - * headers and body — because that is what a document binds and what a - * continuation restores. A transport that answered with the body alone would - * make a fragment's observation a different shape from an authored one, and a - * document that branches on a 404 could not. - */ -export interface FragmentFetchAccess { - fetch(request: { - readonly url: string; - readonly method: string; - readonly headers: Record; - readonly timeout?: number; - }): Operation; -} - -/** - * Where a fragment's relative paths resolve, while it is running. - * - * A directory component scopes its content — `` makes the - * `out.md` inside it mean `nested/out.md` — and the ordinary component does that - * by installing a working directory on the contextual environment. A fragment - * cannot: the contextual environment is exactly what a document, a repository - * component or middleware can answer, and a fragment whose destination could be - * moved from outside would be a fragment admitted for one path and performing - * another. - * - * So the cursor is the evaluation's own. It is created per capture, reset at the - * start of each fragment, and saved and restored around a directory's content by - * the body that scoped it. Nothing outside this module holds a reference. - */ -interface DirectoryCursor { - current: string; -} - -/** Every live operation one captured profile holds, read once and revocable. */ +/** Whether the execution that admitted these components is still running. */ export interface CapturedCapabilities { - readonly files?: FragmentFileAccess; - readonly fetch?: FragmentFetchAccess; - /** Where this evaluation's relative paths currently resolve. Module-private. */ - readonly cursor: DirectoryCursor; - /** - * Begin one fragment, and answer with how to end it. - * - * Reads the host's working directory once, here, rather than once per - * element: every path in one fragment resolves against the directory the run - * was in when that fragment was admitted. The returned operation restores - * whatever was current before, so a fragment produced inside another - * fragment's producer leaves the outer one where it was. - */ - enterFragment(): Operation<() => void>; /** Whether the execution that captured these is still running. */ live(): boolean; - /** Called at execution teardown. Every bound operation refuses afterwards. */ + /** Called at execution teardown. Every admitted implementation refuses afterwards. */ revoke(): void; } /** - * Read each method off the host's objects once, and hand back bound copies. + * The lifetime every admitted implementation in one capture is held to. * - * Bound, so replacing a method on the object the host passed after installation - * reaches nothing. Behind a revocation this execution owns, so an operation - * retained past teardown refuses rather than acting on a filesystem the run no - * longer has a transaction for. + * All a capture owns now. There is no operation to read off a host object and + * nothing to bind, because the implementations are core's own — so what remains + * is the one thing an execution has to be able to say afterwards: this run is + * over, and a definition somebody kept is not a way back into it. */ -export function captureCapabilities(input: { - readonly files?: FragmentFileAccess; - readonly fetch?: FragmentFetchAccess; -}): CapturedCapabilities { +export function captureCapabilities(): CapturedCapabilities { let alive = true; - const live = () => alive; - const guard = (operation: (argument: A) => Operation) => - function* (argument: A): Operation { - if (!alive) { - throw new FragmentCapabilityError(REVOKED_CAPABILITY); - } - return yield* operation(argument); - }; - - const files = input.files; - const fetching = input.fetch; - const directory = files?.workingDirectory.bind(files); - const cursor: DirectoryCursor = { current: "" }; return Object.freeze({ - cursor, - *enterFragment(): Operation<() => void> { - if (!alive) { - throw new FragmentCapabilityError(REVOKED_CAPABILITY); - } - const enclosing = cursor.current; - cursor.current = directory === undefined ? "" : yield* directory(); - return () => { - cursor.current = enclosing; - }; - }, - ...(files === undefined - ? {} - : { - files: Object.freeze({ - checkFilePath: guard(files.checkFilePath.bind(files)), - readTextFile: guard(files.readTextFile.bind(files)), - globFiles: guard(files.globFiles.bind(files)), - writeTextFile: guard(files.writeTextFile.bind(files)), - deleteFile: guard(files.deleteFile.bind(files)), - ensureDirectory: guard(files.ensureDirectory.bind(files)), - *workingDirectory(): Operation { - if (!alive || directory === undefined) { - throw new FragmentCapabilityError(REVOKED_CAPABILITY); - } - return yield* directory(); - }, - }), - }), - ...(fetching === undefined - ? {} - : { fetch: Object.freeze({ fetch: guard(fetching.fetch.bind(fetching)) }) }), - live, + live: () => alive, revoke: () => { alive = false; }, }); } -/** The one path prop the file components take, matching the ordinary schema. */ -const PATH_PROPS: PropsSchema = { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - additionalProperties: false, -}; - -/** The props the fetch component takes, matching the ordinary schema. */ -const FETCH_PROPS: PropsSchema = { - type: "object", - properties: { - url: { type: "string" }, - method: { type: "string" }, - headers: { type: "object" }, - timeout: { type: "string" }, - }, - required: ["url"], - additionalProperties: false, -}; - -/** The schema each capability's component declares. */ -export function capabilityProps(capability: FragmentCapability): PropsSchema { - if (capability === "fetch") { - return FETCH_PROPS; - } - if (capability === "elicit:ask") { - // The component's own declaration, not a copy of it: the admitted element - // and the authored one cannot come to describe different contracts, and - // nothing here asserts that — `components/Elicit.ts` types both schemas - // where it declares them. - return ELICIT_PROPS; - } - // The ordinary component's own schema, not a copy of it: a fragment writes - // the same element an author does. - return capability === "files:glob" ? GLOB_PROPS : PATH_PROPS; -} - -/** - * What a capability binds, for the ones that bind a value rather than render. - * - * Declaring it is what makes the admitted component a value component, exactly - * as it does for the ordinary one: the engine requires `as`, renders nothing, - * and validates what comes back. So a generated `` written without a - * capture is refused for the ordinary reason, before the host's provider is - * asked to traverse anything. - */ -export function capabilityReturns(capability: FragmentCapability): ReturnsSchema | undefined { - if (capability === "elicit:ask") { - // The author's `schema` is the contract core enforces against the answer, so - // what this binds is any JSON value — and declaring it is what makes `as` - // required, exactly as it is for the authored element. - return ELICIT_RETURNS; - } - return capability === "files:glob" ? GLOB_RETURNS : undefined; -} - -/** Which captured operation one admitted entry runs. */ +/** Which ordinary component one admitted entry runs. */ export type FragmentCapability = | "file:read" | "files:glob" @@ -331,303 +134,261 @@ export const CAPABILITY_FORMS: Readonly< "elicit:ask": ["paired"], }); +/** The component canonical core runs for one capability. */ +const OWNERS: Readonly> = Object.freeze({ + "file:read": "File", + "file:write": "File", + "file:delete": "File.Delete", + "directory:ensure": "Dir", + "files:glob": "Glob", + fetch: "Fetch", + "elicit:ask": ELICIT_COMPONENT, +}); + /** - * The component one capability runs, closed over the bound operations. + * The exact definition canonical core supplies for one capability. * - * Built once per capture rather than per invocation, so every element in every - * fragment of one execution runs the same definition — which is also what lets - * two entries under one name reach one definition, as `` and - * `…` do. + * Read from the one place that owns it — the fixed tier for the three + * filesystem names, the core registry for the rest — rather than restated here. + * A copy would be a second component, and a generated fragment behaving + * differently from an authored element is precisely what that drift looks like. */ -export function capabilityDefinition( - name: string, - admitted: { - readonly "self-closing"?: FragmentCapability; - readonly paired?: FragmentCapability; - }, - capabilities: CapturedCapabilities, - requests: readonly { - readonly url: string; - readonly method: string; - readonly headers: Record; - readonly timeout?: number; - }[] = [], -): FunctionComponentDefinition { - const selfClosing = admitted["self-closing"]; - const paired = admitted.paired; - // The schema is the union of what each spelling takes, which for every pair - // core supplies is one schema anyway: `` and `…` both - // take one `path`. - const props = capabilityProps(selfClosing ?? paired ?? "file:read"); - // One name binds one way. Two spellings that returned different shapes would - // be one component whose `as` meant two things, decided by how the element - // happened to be written, so the disagreement is refused here rather than - // resolved by whichever spelling was consulted first. - const returning = boundResult(name, selfClosing, paired); +function capabilityOwner(capability: FragmentCapability): FunctionComponentDefinition { + const name = OWNERS[capability]; + const definition = fixedFilesystemDefinition(name) ?? coreComponentDefinition(name); + if (definition === undefined) { + throw new FragmentCapabilityError(`canonical core supplies no <${name}> to admit.`); + } + return definition; +} - const read = - selfClosing === undefined ? undefined : body(name, selfClosing, capabilities, requests); - const write = paired === undefined ? undefined : body(name, paired, capabilities, requests); +/** The schema an admitted entry's props are validated against. */ +export function capabilityProps(capability: FragmentCapability): PropsSchema { + return capabilityOwner(capability).props; +} - // One name, one definition — even when it holds two identities in two - // classes. `` observes and `…` writes, and the evaluator - // resolves an import by name, so the two must arrive as one definition whose - // *dispatch* separates them. Two definitions would be one name with two - // answers, which it refuses; one undispatched body would let a read reach the - // write path. - if (read !== undefined && write !== undefined) { - return { - kind: "function", - name, - props, - forms: ["self-closing", "paired"], - ...(returning === undefined ? {} : { returns: returning }), - fn: formDispatcher({ forms: "both", "self-closing": read, paired: write }), - }; - } - if (read !== undefined) { - return { - kind: "function", - name, - props, - forms: ["self-closing"], - ...(returning === undefined ? {} : { returns: returning }), - fn: formDispatcher({ - forms: "self-closing", - fn: read, - refuse: () => - new FragmentCapabilityError( - `<${name} /> reads and renders no content of its own, so it is written self-closing.`, - ), - }), - }; - } - if (write !== undefined) { - return { - kind: "function", - name, - props, - forms: ["paired"], - ...(returning === undefined ? {} : { returns: returning }), - fn: formDispatcher({ - forms: "paired", - fn: write, - refuse: () => - new FragmentCapabilityError( - `<${name} /> writes what it renders, so it is written with content rather than ` + - "self-closing.", - ), - }), - }; - } - throw new FragmentCapabilityError(`an evaluation profile admitted "${name}" for no form.`); +/** + * What a capability binds, for the ones that bind a value rather than render. + * + * The owning component's own declaration. Declaring it is what makes the + * admitted element a value component exactly as it does for the authored one: + * the engine requires `as`, renders nothing, and validates what comes back. + */ +export function capabilityReturns(capability: FragmentCapability): ReturnsSchema | undefined { + return capabilityOwner(capability).returns; } -/** What one name binds, when every capability it holds binds the same thing. */ -function boundResult( - name: string, - selfClosing: FragmentCapability | undefined, - paired: FragmentCapability | undefined, -): ReturnsSchema | undefined { - const stated = [selfClosing, paired] - .filter((capability): capability is FragmentCapability => capability !== undefined) - .map(capabilityReturns); - const distinct = new Set(stated.map((schema) => JSON.stringify(schema ?? null))); - if (distinct.size > 1) { - throw new FragmentCapabilityError( - `an evaluation profile admitted "${name}" for two spellings that bind different results. ` + - "One name binds one way, so which of them `as` meant would depend on how the element " + - "was written.", - ); - } - return stated[0]; +/** Which capability one name is admitted for, in each authored spelling. */ +export interface AdmittedForms { + readonly "self-closing"?: FragmentCapability; + readonly paired?: FragmentCapability; } -/** The body one capability runs, closed over the operations this capture bound. */ -function body( +/** One admitted name's implementation, and the dispatcher underneath it. */ +export interface CapabilityImplementation { + readonly definition: FunctionComponentDefinition; + /** + * The owning component's form dispatcher, when it has one. + * + * It travels separately because the definition above runs behind the lifetime + * guard, so what a selection would read off it is core's wrapper rather than + * the component's dispatcher — and a dispatcher read off the wrong function + * selects no body at all. + */ + readonly dispatch?: unknown; +} + +/** + * The implementation one admitted name runs, built once per capture. + * + * One definition per *name*, however many capabilities that name holds: a + * profile admitting `` to read and `…` to write is + * describing one component with two spellings, and the evaluator resolves an + * import by name. Which spelling reaches which body is the component's own + * dispatch, from the scan; which spellings are admitted at all is the entry's, + * in preflight. + */ +export function capabilityImplementation( name: string, - capability: FragmentCapability, + admitted: AdmittedForms, capabilities: CapturedCapabilities, - requests: readonly { - readonly url: string; - readonly method: string; - readonly headers: Record; - readonly timeout?: number; - }[], -) { - if (capability === "fetch") { - return fetchBody(capabilities, requests); + requests: readonly FetchRequest[] = [], +): CapabilityImplementation { + const held = [admitted["self-closing"], admitted.paired].filter( + (capability): capability is FragmentCapability => capability !== undefined, + ); + if (held.length === 0) { + throw new FragmentCapabilityError(`an evaluation profile admitted "${name}" for no form.`); } - if (capability === "elicit:ask") { - // Canonical core's own body, not a copy of its behavior and not a lookup of - // its name: a generated question compiles the same schema, renders the same - // message, is identified in the journal the same way, and reaches a person - // through the same contextual Elicitation Api — so a REPL drawer answers it, - // a surrounding `` region answers it without anybody being asked, - // and a replay restores the retained answer and contacts no provider. - // - // Which implementation this is cannot be chosen at document time. It is - // closed over here, like every other pinned body, so a same-named - // repository file, registration, declaration, middleware or separately - // loaded definition is never what a fragment runs. - return Elicit; - } - const files = capabilities.files; - if (files === undefined) { + const owners = new Set(held.map((capability) => OWNERS[capability])); + if (owners.size > 1) { + // One name is one component. Two capabilities owned by different components + // under one name would be one import with two answers, decided by how the + // element happened to be written. throw new FragmentCapabilityError( - `an evaluation profile admitted "${name}" without stating the filesystem operations it runs.`, + `an evaluation profile admitted "${name}" for two different components: ` + + `${[...owners].join(" and ")}. One name runs one component.`, ); } - const cursor = capabilities.cursor; - if (capability === "file:read") { - return readBody(files, cursor); - } - if (capability === "files:glob") { - return globBody(files, cursor); - } - if (capability === "file:delete") { - return deleteBody(files, cursor); + const owner = capabilityOwner(held[0]); + const inner = owner.fn; + if (typeof inner !== "function") { + throw new FragmentCapabilityError( + `canonical core supplies no invocable <${owner.name}> to admit.`, + ); } - return capability === "file:write" ? writeBody(files, cursor) : ensureBody(files, cursor); -} - -function readBody(files: FragmentFileAccess, cursor: DirectoryCursor) { - return function* read(props: Record): Operation { - const requested = String(props.path); - const text = yield* files.readTextFile({ cwd: cursor.current, path: requested }); - if (!text.ok) { - throw refusedRequest(refusal(requested, "read")); - } - return text.value; - }; + const fn = + owner.name === "Fetch" + ? bounded(admitted, capabilities, boundedRequests(inner, requests)) + : bounded(admitted, capabilities, inner); + return Object.freeze({ + definition: Object.freeze({ ...owner, name, fn }), + ...(isFormDispatcher(inner) ? { dispatch: inner } : {}), + }); } /** - * The search body, held to the same source rules the ordinary `` is. + * One admitted implementation, held to this execution and reporting a refusal + * of the request as one. + * + * Two jobs, and both of them are the adapter's rather than the component's. The + * lifetime is the execution's: a definition retained past teardown refuses + * instead of reaching a filesystem the run no longer holds a transaction for. + * The classification is the caller's: a host driving a loop needs to know + * whether the fragment's own text was refused, and only the code that raised + * the failure knows that. * - * The patterns are checked before the provider is asked, so a pattern that - * cannot match anything is a refusal rather than an empty result, and a fragment - * whose text is wrong performs no traversal at all. The sentences come from - * `glob-source.ts`, so an author and a generated fragment are told the same - * thing about the same pattern — and a provider's own diagnostic never reaches - * either, because the failure is normalized from the parsed failure data rather - * than from its message. + * It opens no scope and selects no body, so the component's own dispatcher + * still runs in the frame the engine issued it for. */ -function globBody(files: FragmentFileAccess, cursor: DirectoryCursor) { - return function* search(props: Record): Operation { - const include = globPatterns("include", props.include); - if (!include.ok) { - throw refusedRequest(include.error.message); - } - const exclude = globPatterns("exclude", props.exclude); - if (!exclude.ok) { - throw refusedRequest(exclude.error.message); - } - const found = yield* files.globFiles({ - cwd: cursor.current, - include: include.value, - exclude: exclude.value, - }); - if (!found.ok) { - throw refusedRequest( - globFailure(parseFilesFailure(found.error), [...include.value, ...exclude.value]), - ); +function bounded( + admitted: AdmittedForms, + capabilities: CapturedCapabilities, + inner: FunctionComponent, +): FunctionComponent { + return function* held( + props: Record, + invocation: ComponentInvocation, + ): Operation { + if (!capabilities.live()) { + throw new FragmentCapabilityError(REVOKED_CAPABILITY); } - return found.value; - }; -} - -function deleteBody(files: FragmentFileAccess, cursor: DirectoryCursor) { - return function* remove(props: Record): Operation { - const requested = String(props.path); - const removed = yield* files.deleteFile({ cwd: cursor.current, path: requested }); - if (!removed.ok) { - throw refusedRequest(refusal(requested, "delete")); + try { + return yield* inner(props, invocation); + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + const reason = refusedReason(admitted, props, invocation, error); + if (reason === undefined) { + throw error; + } + throw markGeneratedRequestRefusal(error, reason); } - return ""; }; } -function writeBody(files: FragmentFileAccess, cursor: DirectoryCursor) { - return function* write(props: Record): Operation { - const requested = String(props.path); - const cwd = cursor.current; - // The path is admitted before the content is produced, so a fragment whose - // destination is refused renders nothing at all. - const admitted = yield* files.checkFilePath({ cwd, path: requested }); - if (!admitted.ok) { - throw refusedRequest(refusal(requested, "write")); - } - const text = yield* rendered(requested); - // Resolved against the directory this element was written in, captured - // before its content ran: a directory the content itself scoped must not - // move where this write lands. - const written = yield* files.writeTextFile({ cwd, path: requested, content: text }); - if (!written.ok) { - throw refusedRequest(refusal(requested, "write")); - } - return ""; - }; -} +/** The verb a refusal of each filesystem capability is reported with. */ +const VERBS: Readonly>> = Object.freeze({ + "file:read": "read", + "file:write": "write", + "file:delete": "delete", + "directory:ensure": "create", +}); -function ensureBody(files: FragmentFileAccess, cursor: DirectoryCursor) { - return function* ensure(props: Record): Operation { - const requested = String(props.path); - const enclosing = cursor.current; - // The directory exists before its content runs: content that ran in a - // directory the ensure was going to refuse would be content run somewhere - // nobody chose. - const made = yield* files.ensureDirectory({ cwd: enclosing, path: requested }); - if (!made.ok) { - throw refusedRequest(refusal(requested, "create")); - } - // And it scopes what it renders, which is what makes `` - // inside it mean this directory's `out.md`. Scoped through the evaluation's - // own cursor rather than the contextual environment, so nothing outside the - // fragment can move where its content writes. - cursor.current = resolve(enclosing, requested); - try { - return yield* rendered(requested); - } finally { - cursor.current = enclosing; - } - }; +/** + * The safe reason this failure carries, when it is a refusal of the request. + * + * Core's own fixed sentence, not the component's. The component's message is + * safe to *print* — it names the path the document wrote and a phrase from a + * fixed vocabulary — but a write that failed because its content failed to + * expand reports what that content said, and the content of a generated + * fragment is text nobody audited. A refusal reason travels back to whatever is + * driving the loop, so it is built here from the capability and the authored + * path, and from nothing the fragment produced. + * + * The glob sentences are the exception and are already written for this: a + * pattern a fragment wrote wrongly is reported by `glob-source.ts` in the same + * words an author would be told, and no provider diagnostic reaches them. + * + * Everything else is unmarked, which is the safe default. An absent provider, a + * broken provider contract, a revoked execution, a cancellation and a durable + * failure are not the request being refused, and calling any of them one would + * invite another turn at work that cannot succeed. + */ +function refusedReason( + admitted: AdmittedForms, + props: Record, + invocation: ComponentInvocation, + error: unknown, +): string | undefined { + if (error instanceof GlobError) { + return error.message; + } + if ( + !( + error instanceof FileAccessError || + error instanceof FileDeleteError || + error instanceof DirInvocationError + ) + ) { + return undefined; + } + const verb = VERBS[writtenCapability(admitted, invocation) ?? "fetch"]; + return verb === undefined + ? undefined + : `an admitted fragment could not ${verb} ${JSON.stringify(String(props.path))}.`; } /** - * One authored path against the directory it was written in. + * Which of a name's capabilities this element was written as. * - * An absolute path is used as written — a workflow run's logical root is one — - * and anything else is joined, which is what nesting means. + * Read from the authored shape, which is the only thing that separates + * `` from `…` once both are admitted under one name. This + * is **not** how the effect was selected: the component's own dispatcher + * already did that, from the scan, before its body ran — and the failure this + * words a sentence for has already happened. A forged invocation can therefore + * change which verb a refusal is phrased with and nothing else, which is why + * reading it here is observation rather than authentication. */ -function resolve(enclosing: string, path: string): string { - if (path.startsWith("/")) { - return path; +function writtenCapability( + admitted: AdmittedForms, + invocation: ComponentInvocation, +): FragmentCapability | undefined { + try { + return invocation.hasContent() ? admitted.paired : admitted["self-closing"]; + } catch { + return undefined; } - const base = enclosing.endsWith("/") ? enclosing.slice(0, -1) : enclosing; - return `${base}/${path}`; } -function fetchBody( - capabilities: CapturedCapabilities, - requests: readonly { - readonly url: string; - readonly method: string; - readonly headers: Record; - readonly timeout?: number; - }[], -) { - return function* perform(props: Record): Operation { - const fetching = capabilities.fetch; - if (fetching === undefined) { - throw new FragmentCapabilityError( - "an admitted fragment asked for a request, and this host stated no transport for one.", - ); - } - // Preflight already compared this element against the ceiling, from the - // scan, before the fragment's first effect. This finds the admitted request - // it matched and performs *that* — never the props, which would let a - // header the ceiling normalized away travel anyway. +/** + * The admitted request this element matched, performed under its entry's + * ceiling. + * + * Preflight already compared the element against the ceiling, from the scan, + * before the fragment's first effect. This finds the admitted request it + * matched and performs *that* — never the props, which would let a header the + * ceiling normalized away travel anyway, and never a timeout read from + * wherever the performance happened rather than the one the host resolved when + * it stated the profile. + * + * The ceiling is then installed as an ordinary `Permissions` restriction for + * the duration of this one invocation, so it stays in force at the place the + * request is actually sent: contextual `API.Fetch` middleware may observe, + * sign or route what goes out, and a transformation that moves the request off + * this entry's ceiling is refused by the terminal rather than performed. + * + * The journal, the response detachment and the status rule are the ordinary + * component's, reached through the shared observation it exports: one element + * is one durable request, and a continuation restores what came back rather + * than asking again. + */ +function boundedRequests( + inner: FunctionComponent, + requests: readonly FetchRequest[], +): FunctionComponent { + return function* performed(props: Record): Operation { const url = String(props.url); const admitted = requests.find((request) => request.url === url); if (admitted === undefined) { @@ -635,21 +396,24 @@ function fetchBody( "an admitted fragment asked for a request this host did not admit.", ); } - // Journaled on exactly the terms an authored `` is, through the same - // module: one element is one durable request, and a continuation restores - // what came back rather than asking again. const expansion = yield* getExpansion(); - const retained = yield* persistFetch( - { - id: expansion.id, - ...(expansion.position === undefined ? {} : { position: expansion.position }), - }, - admitted, - () => fetching.fetch(admitted), - ); + const retained = yield* scoped(function* () { + yield* Permissions.around({ + fetch: ([input, init], next) => + permitted(requests, input, init) ? next(input, init) : false, + }); + return yield* persistFetch( + { + id: expansion.id, + ...(expansion.position === undefined ? {} : { position: expansion.position }), + }, + admitted, + () => observeFetch(admitted), + ); + }); // The status rule is the authored one: a fragment binds nothing, so a - // response it cannot branch on must not be handed back as though it were - // an answer. + // response it cannot branch on must not be handed back as though it were an + // answer. const response = parseResponseRecord(retained); if (response.status < 200 || response.status > 299) { throw new FragmentCapabilityError( @@ -661,54 +425,20 @@ function fetchBody( } /** - * A refusal that names the path and the direction, and nothing else. - * - * The provider's own reason is deliberately dropped. A generated fragment's - * failure is serialized into the journal with its message, and a host provider's - * diagnostic can carry an absolute path, a root identifier or a transaction - * detail that the run has no business publishing on behalf of text nobody - * audited. - */ -function refusal(path: string, verb: string): string { - return `an admitted fragment could not ${verb} ${JSON.stringify(path)}.`; -} - -/** - * A refusal of what the fragment asked for. - * - * An ordinary provider `Err` — a file that is not there, a directory that - * cannot be searched — and a pattern or form the fragment wrote wrongly are all - * refusals of the request itself. The revocation refusal is deliberately not - * one of them: an operation whose execution has ended is this run being over, - * and what the fragment asked for is not what was wrong with it. - * - * The distinction is the whole of what this states. What a caller does with a - * refused request — ask again, stop, report it — is that caller's decision, and - * nothing here holds an opinion about it. - */ -function refusedRequest(message: string): FragmentCapabilityError { - return markGeneratedRequestRefusal(new FragmentCapabilityError(message), message); -} - -/** - * The rendered children, or a failure instead of a partial write. + * Whether the request about to be sent is still one this entry admits. * - * `content()` is a failure boundary, and for a component that *writes* what it - * rendered, embedding a printed error in the destination would be worse than - * useless. So this recovers from the boundary and fails the invocation: nothing - * reaches the provider, and the target keeps whatever it already held. + * The destination and the method, which are what a ceiling is about: a + * transformation that sends the request somewhere else, or sends it as a + * different method, is performing a request the host never admitted. Headers + * are deliberately not compared — the host that stated this ceiling is the same + * host whose middleware signs and routes what leaves it, and a restriction that + * refused its own authentication header would make that unusable. */ -function* rendered(path: string): Operation { - try { - return yield* content(); - } catch (error) { - if (error instanceof ContentError || error instanceof ProjectedContentError) { - throw new FragmentCapabilityError( - `an admitted fragment's content for ${JSON.stringify(path)} failed to render, so nothing ` + - "was written.", - { cause: error }, - ); - } - throw error; - } +function permitted( + requests: readonly FetchRequest[], + input: string, + init: { readonly method?: string } | undefined, +): boolean { + const method = init?.method ?? "GET"; + return requests.some((request) => request.url === input && request.method === method); } diff --git a/packages/core/tests/evaluate-component.test.ts b/packages/core/tests/evaluate-component.test.ts index 90f2d703c..14acee034 100644 --- a/packages/core/tests/evaluate-component.test.ts +++ b/packages/core/tests/evaluate-component.test.ts @@ -37,7 +37,7 @@ import { elicitWriteEntry } from "../host.ts"; import { Elicitation } from "../src/elicitation-api.ts"; import type { ElicitationRequest } from "../src/elicitation-api.ts"; import { retainedSource } from "../src/root-source.ts"; -import { recordedFiles } from "./support/fragment-files.ts"; +import { recordedFiles, useRecordedFiles } from "./support/fragment-files.ts"; import type { RecordedFiles } from "./support/fragment-files.ts"; import { answerProvider, implementation } from "./support/answer-provider.ts"; import type { Implementation, ProviderOptions } from "./support/answer-provider.ts"; @@ -178,7 +178,6 @@ describe("protected generated component answers", () => { // Deliberately a host's own identity, and deliberately not core's: it is // what a continuation compares, and nothing here may rewrite it. write: [directoryEntry({ origin: "test://host", key: "Dir", revision: "1" }, "Dir")], - files: recordedFiles(), }); try { const captured = yield* prepared.seal(new Map()); @@ -223,7 +222,6 @@ describe("protected generated component answers", () => { // admitted under the name it does own. { ...globReadEntry(), name: "File" }, ], - files: recordedFiles(), }); try { const captured = yield* prepared.seal(new Map()); @@ -401,7 +399,7 @@ const NOTE = "the retained note\n"; /** A read-only profile over one recorder. */ function reading(files: RecordedFiles): ExecutionInstallation { - return { evaluation: { read: [fileReadEntry()], files } }; + return { evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }; } /** A profile offering both classes over one recorder. */ @@ -414,8 +412,8 @@ function both(files: RecordedFiles): ExecutionInstallation { directoryEntry({ origin: "test://host", key: "Dir", revision: "1" }, "Dir"), fileDeleteEntry(), ], - files, }, + install: () => useRecordedFiles(files), }; } @@ -482,35 +480,45 @@ describe("Tier FE — a program the document holds", () => { }); describe("Tier FE — a fragment reaches the captured operations and nothing else", () => { - it("FE13: a Files provider installed around the document is not what a fragment reads", function* () { - const files = recordedFiles({ "notes.md": NOTE }); - const reachedDocumentProvider: string[] = []; + /** + * One chain, crossed by both. + * + * This row used to assert the opposite: a fragment reached the operations the + * host handed the profile, and a provider installed around the document was + * never consulted. That private transport is gone. An admitted name now runs + * the ordinary component, so a fragment's read is resolved through + * contextual `API.Files` exactly as an authored read is — and the discriminating + * evidence is that *one* middleware sees both payloads and that what it + * transforms is what the fragment gets. + * + * What protects the fragment was never which object answered. It is the names + * the host admitted, the identity a continuation is compared against, and the + * effective `Permissions` of the scope the operation runs in. + */ + it("FE13: one Files middleware sees the document's read and the fragment's", function* () { + const files = recordedFiles({ "notes.md": NOTE, "renamed.md": "the renamed note\n" }); + const crossed: string[] = []; const output = yield* scoped(function* () { - // A provider installed exactly where a document, a repository component or - // a middleware package would install one — nearer than anything the host - // set up, and answering every read. - yield* API.Files.around( - { - // deno-lint-ignore require-yield - *readTextFile([input]): Operation { - reachedDocumentProvider.push(String(input.path)); - throw new Error("the document's provider answered"); - }, + // Installed where a document, a repository component or a middleware + // package installs one, and it both observes and transforms. + yield* API.Files.around({ + *readTextFile([input], next) { + crossed.push(String(input.path)); + return yield* next(input.path === "notes.md" ? { ...input, path: "renamed.md" } : input); }, - { at: "min" }, - ); + }); return yield* run( `\\n'} as="answer" />\n\n\n`, [reading(files)], ); }); - // The fragment read through the captured operation. The provider the - // document installed was never consulted — it would have thrown. - expect(String(output)).toContain("the retained note"); - expect(files.performed).toEqual(["read notes.md"]); - expect(reachedDocumentProvider).toEqual([]); + // The read the fragment asked for crossed the middleware, and the provider + // performed the path the middleware substituted. + expect(crossed).toEqual(["notes.md"]); + expect(files.performed).toEqual(["read renamed.md"]); + expect(String(output)).toContain("the renamed note"); }); it("FE13: a fragment cannot glob, however the document's provider is arranged", function* () { @@ -608,24 +616,25 @@ describe("Tier FE — the paired form produces its own program", () => { it("FE2: a producer keeps its own permission while the fragment does not", function* () { // The producer's content runs at the document's site, so its `` is - // the ordinary component and reaches the document's own provider. The - // program it renders reaches only the captured operations. Both are counted - // here, separately: narrowing what a fragment may *name* must not narrow - // what the producer may *do* to write it. + // the ordinary component written where an author wrote it. The program it + // renders runs under the admitted tables instead. Both now cross the same + // Files chain — what differs is what each may *name*, and narrowing a + // fragment's vocabulary must not narrow what the producer may do to write + // it. const files = recordedFiles({ "notes.md": NOTE }); - const documentReads: string[] = []; + const crossed: string[] = []; const output = yield* scoped(function* () { - yield* API.Files.around( - { - // deno-lint-ignore require-yield - *readTextFile([input]): Operation> { - documentReads.push(String(input.path)); - return Ok(`\n`); - }, + yield* API.Files.around({ + *readTextFile([input], next) { + crossed.push(String(input.path)); + // The producer's own input is answered here, as a document's provider + // may; the fragment's read is delegated to the one underneath. + return input.path === "producer-input.md" + ? Ok(`\n`) + : yield* next(input); }, - { at: "min" }, - ); + }); return yield* run( `\n\n\n\n` + `\n`, @@ -633,7 +642,8 @@ describe("Tier FE — the paired form produces its own program", () => { ); }); - expect(documentReads).toEqual(["producer-input.md"]); + // One chain, two payloads, in the order the two sites ran. + expect(crossed).toEqual(["producer-input.md", "notes.md"]); expect(files.performed).toEqual(["read notes.md"]); expect(String(output)).toContain("the retained note"); }); @@ -996,10 +1006,27 @@ describe("Tier FE — an execution with no ceiling has no evaluation", () => { ); }); - it("FE5: a profile admitting a file entry without operations is refused", function* () { - expect( - yield* refusal(run(`nothing asked for\n`, [{ evaluation: { read: [fileReadEntry()] } }])), - ).toContain("without stating the filesystem operations"); + /** + * A profile states names, and no operations at all. + * + * The inverse of what this row used to assert. A file entry without stated + * filesystem operations was refused while the profile carried a private + * transport, because an entry admitting an operation the profile could not + * perform was a profile that admitted nothing usable. There is nothing left + * to state: the entry runs the ordinary component, which performs its work + * through whichever provider is installed where the fragment runs — so an + * entry alone is a complete admission, and an absent provider is fatal + * exactly where it is fatal for an authored element. + */ + it("FE5: a file entry states no operations, and reads what the provider has", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const output = yield* run( + `\\n'} as="answer" />\n\n\n`, + [{ evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }], + ); + + expect(String(output)).toContain("the retained note"); + expect(files.performed).toEqual(["read notes.md"]); }); }); @@ -1180,8 +1207,8 @@ describe("Tier FE — one occurrence, one durable decision", () => { identity: { origin: "@executablemd/core", key: "File:read", revision: "99" }, }, ], - files: second, }, + install: () => useRecordedFiles(second), }; const failed = yield* refusal(run(source, [widened], new InMemoryStream(partial))); @@ -1299,10 +1326,69 @@ describe("Tier FE — protection settles which implementation runs, and grants n ); }); - // The fragment read the captured operation's file, not the forged one. - expect(String(output)).toContain("the retained note"); - expect(String(output)).not.toContain("forged"); - expect(files.performed).toEqual(["read notes.md"]); + // What a document controls is the provider, and that is now deliberately + // shared: the fragment's read crossed the one `` installed, exactly + // as the document's own read would have. A provider is not a permission. + expect(String(output)).toContain("forged notes.md"); + expect(files.performed).toEqual([]); + }); + + /** + * And what it still cannot do is widen the profile. + * + * The half of the row above that did not move. A component may answer the + * operation; it cannot add a name to the admitted vocabulary, cannot change + * the identity a continuation is compared against, and cannot give a + * selection a class the host installed no table for — none of which is + * reachable from anything a document holds. + */ + it("FE29: a document that answers the operation still admits no name", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const failed = yield* refusal( + scoped(function* () { + yield* registerComponents([ + { + name: "Forge", + origin: "test://forge", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + // Everything a document can reach: its own provider, and its own + // registration under the name a fragment would have to write. + yield* API.Files.around( + { + // deno-lint-ignore require-yield + *readTextFile([input]): Operation> { + return Ok(`forged ${String(input.path)}`); + }, + }, + { at: "min" }, + ); + yield* registerComponents([ + { + name: "Glob", + origin: "test://forge", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return "forged search"; + }, + }, + ]); + return yield* content(); + }, + }, + ]); + return yield* run( + `\n\\n'} />\n\n`, + [reading(files)], + ); + }), + ); + + // Not "the search failed" — `` is not a name this fragment has, and a + // registration under that name did not give it one. + expect(failed).toContain("did not admit"); + expect(files.performed).toEqual([]); }); }); @@ -1562,8 +1648,8 @@ describe("Tier FE34 — the shared read profile", () => { evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], write: [fileWriteEntry(), fileDeleteEntry()], - files, }, + install: () => useRecordedFiles(files), }; } @@ -1694,16 +1780,15 @@ describe("Tier FE34 — the shared read profile", () => { `\\n'} as="answer" />\n\n\n`, [ { - evaluation: { - read: [globReadEntry()], - files: { + evaluation: { read: [globReadEntry()] }, + install: () => + useRecordedFiles({ ...files, *globFiles(input) { searched.push(input.cwd); return yield* files.globFiles(input); }, - }, - }, + }), }, ], ); @@ -1912,16 +1997,15 @@ describe("Tier FE34 — the shared read profile", () => { try { yield* run(`\\n'} />\n`, [ { - evaluation: { - read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], - files: { + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + install: () => + useRecordedFiles({ ...broken, // deno-lint-ignore require-yield *readTextFile(): Operation { throw new Error("the Files provider failed"); }, - }, - }, + }), }, ]); } catch (error) { @@ -1960,8 +2044,8 @@ describe("Tier FE34 — the shared read profile", () => { forms: ["self-closing"], }, ], - files, }, + install: () => useRecordedFiles(files), componentAnswers: [answerProvider("Held", held)], }; @@ -2016,8 +2100,8 @@ describe("Tier FE34 — the shared read profile", () => { forms: ["self-closing"], }, ], - files, }, + install: () => useRecordedFiles(files), }, ]), ); @@ -2052,7 +2136,6 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit /** The entry a host states for a provider-backed name. */ function admits( identity: { origin?: string; key?: string; revision?: string } = {}, - files: RecordedFiles = recordedFiles(), ): FragmentEvaluationInput { return { read: [ @@ -2067,7 +2150,6 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit forms: ["self-closing"], }, ], - files, }; } @@ -2163,7 +2245,11 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit return name === "Open" ? B.definition : definition; }, }); - return yield* run(OPEN, [{ ...backed(A), evaluation: admits({}, files) }], stream); + return yield* run( + OPEN, + [{ ...backed(A), evaluation: admits({}), install: () => useRecordedFiles(files) }], + stream, + ); }), ); @@ -2320,7 +2406,8 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit OPEN, [ { - evaluation: admits({}, files), + evaluation: admits({}), + install: () => useRecordedFiles(files), componentAnswers: [answerProvider("Open", A.definition, options)], }, ], @@ -2347,7 +2434,8 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit OPEN, [ { - evaluation: admits({ revision: "1" }, files), + evaluation: admits({ revision: "1" }), + install: () => useRecordedFiles(files), // The provider claims honestly — for a revision this host did not // admit. An admitted identity is the exact implementation, and a // changed revision is a changed grant. @@ -2470,7 +2558,8 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit `\\n'} as="answer" />\n\n\n`, [ { - evaluation: { read: [fileReadEntry()], files }, + evaluation: { read: [fileReadEntry()] }, + install: () => useRecordedFiles(files), componentAnswers: [ answerProvider("Open", implementation("Open", "A ran").definition, { lookups }), ], @@ -2509,15 +2598,16 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => evaluation: { read: [fileReadEntry()], write: [fileWriteEntry(), elicitWriteEntry()], - files, }, + install: () => useRecordedFiles(files), }; } /** The same tables, with the question left out of the one that holds it. */ function withoutElicit(files: RecordedFiles): ExecutionInstallation { return { - evaluation: { read: [fileReadEntry()], write: [fileWriteEntry()], files }, + evaluation: { read: [fileReadEntry()], write: [fileWriteEntry()] }, + install: () => useRecordedFiles(files), }; } @@ -2815,8 +2905,8 @@ describe("Tier FE36 — canonical `` is a write a person answers", () => identity: { origin: "@executablemd/core", key: "Elicit", revision: "99" }, }, ], - files: second, }, + install: () => useRecordedFiles(second), }; const requests: ElicitationRequest[] = []; const failed = yield* refusal( diff --git a/packages/core/tests/evaluate-loaded-copy.test.ts b/packages/core/tests/evaluate-loaded-copy.test.ts index 29e9e7e8e..b7e2828ce 100644 --- a/packages/core/tests/evaluate-loaded-copy.test.ts +++ b/packages/core/tests/evaluate-loaded-copy.test.ts @@ -34,7 +34,7 @@ import { fileDeleteEntry, fileReadEntry, fileWriteEntry } from "../host.ts"; import type { ExecutionInstallation } from "../host.ts"; import { registerComponents } from "../src/components/registration.ts"; import { retainedSource } from "../src/root-source.ts"; -import { recordedFiles } from "./support/fragment-files.ts"; +import { recordedFiles, useRecordedFiles } from "./support/fragment-files.ts"; import type { RecordedFiles } from "./support/fragment-files.ts"; const ROOT_PATH = "evaluate.md"; @@ -42,7 +42,7 @@ const NOTE = "the retained note\n"; /** A read-only profile: no write table at all. */ function reading(files: RecordedFiles): ExecutionInstallation { - return { evaluation: { read: [fileReadEntry()], files } }; + return { evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }; } /** A profile offering both classes. */ @@ -51,8 +51,8 @@ function both(files: RecordedFiles): ExecutionInstallation { evaluation: { read: [fileReadEntry()], write: [fileWriteEntry(), fileDeleteEntry()], - files, }, + install: () => useRecordedFiles(files), }; } diff --git a/packages/core/tests/evaluate-provider-lifetime.test.ts b/packages/core/tests/evaluate-provider-lifetime.test.ts index 627ffa127..5502474f5 100644 --- a/packages/core/tests/evaluate-provider-lifetime.test.ts +++ b/packages/core/tests/evaluate-provider-lifetime.test.ts @@ -37,7 +37,6 @@ import { prepareEvaluationProfile } from "../src/evaluation-profile.ts"; import type { ComponentAnswerEntry, ResolvedAnswer } from "../src/evaluation-profile.ts"; import { REVOKED_CAPABILITY } from "../src/fragment-capabilities.ts"; import { retainedSource } from "../src/root-source.ts"; -import { recordedFiles } from "./support/fragment-files.ts"; import { answerProvider, implementation } from "./support/answer-provider.ts"; import type { Implementation } from "./support/answer-provider.ts"; import type { ComponentInvocation } from "../src/invocation-identity.ts"; @@ -60,7 +59,7 @@ function answerEntry(name: string): ComponentAnswerEntry { /** The entry a host states for a provider-backed name. */ function admits(): FragmentEvaluationInput { - return { read: [answerEntry("Open")], files: recordedFiles() }; + return { read: [answerEntry("Open")] }; } /** @@ -395,7 +394,6 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur { evaluation: { read: [answerEntry("Open"), answerEntry("Other")], - files: recordedFiles(), }, componentAnswers: [ answerProvider("Open", A.definition, { retain: stale }), @@ -451,7 +449,6 @@ describe("Tier FE15 — a provider's answer belongs to the execution that captur { evaluation: { read: [answerEntry("Open"), answerEntry("Other")], - files: recordedFiles(), }, componentAnswers: [ { diff --git a/packages/core/tests/evaluation-profile.test.ts b/packages/core/tests/evaluation-profile.test.ts index a3df99628..291f35b37 100644 --- a/packages/core/tests/evaluation-profile.test.ts +++ b/packages/core/tests/evaluation-profile.test.ts @@ -16,7 +16,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import type { Operation } from "effection"; import { scoped, useScope } from "effection"; -import { installIdentities } from "../src/invocation-identity.ts"; +import { installIdentities, isFormDispatcher } from "../src/invocation-identity.ts"; import type { ComponentRouting, ProtectedSite } from "../src/invocation-identity.ts"; import { @@ -32,6 +32,7 @@ import { CORE_REVISION } from "../src/generated-xmd.ts"; import { props as globProps } from "../src/components/Glob.ts"; import { props as elicitProps } from "../src/components/Elicit.ts"; import { capabilityReturns } from "../src/fragment-capabilities.ts"; +import { fixedFilesystemDefinition } from "../src/components/fixed-filesystem.ts"; import type { CapabilityEntry, EvaluationProfile, @@ -41,7 +42,6 @@ import type { ResolvedAnswers, } from "../src/evaluation-profile.ts"; import type { Json } from "../src/types.ts"; -import { recordedFiles } from "./support/fragment-files.ts"; describe("protected route projection", () => { function installation() { @@ -221,7 +221,6 @@ function* capture(overrides: Partial = {}): Operation { expect(yield* refusal({ read: [] })).toContain("no component at all"); }); - it("EP17: a file entry with no operations behind it refuses", function* () { - // A host that admits `` and states no operations has admitted - // something it cannot perform. Refused at capture — rather than admitted and - // left to fall through to whichever provider a document installed, which is - // exactly the reach this profile exists to remove. - expect( - yield* refusal({ - read: [entry()], - files: undefined, - }), - ).toContain("without stating the filesystem operations"); + it("EP17: a file entry states no operations, and runs the component core owns", function* () { + // The inverse of what this row asserted while the profile carried a private + // transport, where a host admitting `` without stating operations + // had admitted something it could not perform. There is nothing left for a + // host to state: the entry runs canonical core's own component, which + // performs its work through whichever provider is installed where the + // fragment runs. So the admission is complete on its own — and an absent + // provider is fatal at the operation, exactly as it is for an authored + // element. + const captured = yield* capture({ read: [fileReadEntry()] }); + const admitted = captured.read[0]; + + expect(admitted?.kind).toBe("capability"); + expect(admitted?.capability).toBe("file:read"); + // The owning component's own contract, read off it rather than restated. + expect(admitted?.props).toEqual(fixedFilesystemDefinition("File")?.props); + // And the component's own dispatcher travels with it, so canonical + // selection enters the body the scan chose rather than a wrapper's. + expect(isFormDispatcher(admitted?.dispatch)).toBe(true); }); it("EP21: core's own read entries state exactly what a host may admit them as", function* () { @@ -500,17 +507,21 @@ describe("Tier EP — a profile a host cannot state", () => { expect(Object.keys(stated)).not.toContain("capability"); }); - it("EP23: one name admitted for two spellings that bind differently refuses", function* () { + it("EP23: one name admitted for two different components refuses", function* () { // `` holds two capabilities under one name legitimately, because both - // spellings render. A name whose spellings disagreed about what `as` meant - // would be one component with two answers, decided by how the element - // happened to be written. + // belong to the same component: its two spellings are two bodies of one + // ``. Two capabilities owned by *different* components would be one + // name with two answers, decided by how the element happened to be written + // — and the two would disagree about what `as` means, which is how this + // same mistake used to be caught. The owning component is what is compared + // now, because that is the earlier and more general fact: a name that runs + // two components is already wrong, whatever either of them binds. expect( yield* refusal({ read: [entry({ name: "File", capability: "files:glob", forms: ["self-closing"] })], write: [entry({ name: "File", capability: "file:write", forms: ["paired"] })], }), - ).toContain("bind different results"); + ).toContain("two different components"); }); it("EP16: forms are canonically ordered, so two statements of one pair match", function* () { @@ -565,7 +576,7 @@ describe("Tier EP — a provider-backed name states one identity", () => { /** Prepare a profile holding these entries, without sealing it. */ function prepare(input: Partial) { - return prepareEvaluationProfile({ read: [entry()], files: recordedFiles(), ...input }); + return prepareEvaluationProfile({ read: [entry()], ...input }); } /** What preparing this profile refused with. */ diff --git a/packages/core/tests/generated-composition.test.ts b/packages/core/tests/generated-composition.test.ts index f130d2779..1ed83f84e 100644 --- a/packages/core/tests/generated-composition.test.ts +++ b/packages/core/tests/generated-composition.test.ts @@ -29,7 +29,7 @@ import { executeInstalled } from "../host.ts"; import { fileDeleteEntry, fileReadEntry, fileWriteEntry } from "../host.ts"; import type { ExecutionInstallation } from "../host.ts"; import { retainedSource } from "../src/root-source.ts"; -import { recordedFiles } from "./support/fragment-files.ts"; +import { recordedFiles, useRecordedFiles } from "./support/fragment-files.ts"; import type { RecordedFiles } from "./support/fragment-files.ts"; import { answerProvider } from "./support/answer-provider.ts"; import { registerComponents } from "../src/components/registration.ts"; @@ -40,11 +40,14 @@ const NOTE = "the retained note\n"; const OTHER = "the other note\n"; function reading(files: RecordedFiles): ExecutionInstallation { - return { evaluation: { read: [fileReadEntry()], files } }; + return { evaluation: { read: [fileReadEntry()] }, install: () => useRecordedFiles(files) }; } function writing(files: RecordedFiles): ExecutionInstallation { - return { evaluation: { read: [], write: [fileWriteEntry(), fileDeleteEntry()], files } }; + return { + evaluation: { read: [], write: [fileWriteEntry(), fileDeleteEntry()] }, + install: () => useRecordedFiles(files), + }; } function both(files: RecordedFiles): ExecutionInstallation { @@ -52,8 +55,8 @@ function both(files: RecordedFiles): ExecutionInstallation { evaluation: { read: [fileReadEntry()], write: [fileWriteEntry(), fileDeleteEntry()], - files, }, + install: () => useRecordedFiles(files), }; } @@ -328,10 +331,8 @@ describe("Tier FE31 — Json is composition, not an effect", () => { const message = yield* refusal( run(evaluating(`\n`), [ { - evaluation: { - read: [{ ...fileReadEntry(), name: "Json" }], - files, - }, + evaluation: { read: [{ ...fileReadEntry(), name: "Json" }] }, + install: () => useRecordedFiles(files), }, ]), ); diff --git a/packages/core/tests/shared-operation-permissions.test.ts b/packages/core/tests/shared-operation-permissions.test.ts new file mode 100644 index 000000000..2255cbe6a --- /dev/null +++ b/packages/core/tests/shared-operation-permissions.test.ts @@ -0,0 +1,603 @@ +/** + * Tier PRM — one read-only region, and everything inside it. + * + * `Permissions` is a runtime Api and its composition is proved beside it (Tier + * PM). What these rows are about is the property that only a document can show: + * an authored element, a generated fragment's element and a direct contextual + * call are *the same operation*, so one restriction decides all three. Before + * U2 that was not even expressible — a generated element reached a filesystem + * the profile handed it, which no middleware and no policy in the document's + * scope could see. + * + * Everything here drives the real host provider against a real directory, and + * every claim is checked twice: once in what the document reported, and once in + * what the filesystem actually holds afterwards. A refusal that printed the + * right sentence and wrote the file anyway would pass the first and fail the + * second. + * + * The region is a component rather than a wrapper the harness installs, + * because the thing under test is *scoped* permission: a region has an inside + * and an outside, and the sibling written after it is what makes the outside + * observable. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, until } from "effection"; +import type { Operation } from "effection"; +import { exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { API, Permissions, useHostFiles } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { Json } from "@executablemd/durable-streams"; +import { mkdir, mkdtemp, realpath } from "node:fs/promises"; +import { createServer } from "node:http"; +import type { IncomingMessage, Server, ServerResponse } from "node:http"; +import { withResolvers } from "effection"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { collect } from "../src/collect.ts"; +import { executeInstalled } from "../host.ts"; +import { retainedSource } from "../src/root-source.ts"; +import type { ExecutionInstallation } from "../host.ts"; +import { + directoryEntry, + fetchEntry, + fileDeleteEntry, + fileReadEntry, + fileWriteEntry, + globReadEntry, + syntaxReadEntry, +} from "../host.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { content } from "../src/component-api.ts"; +import { readFileText, writeFileText } from "../src/files.ts"; +import { cwd } from "@executablemd/runtime"; + +const ROOT_PATH = "document.md"; +const SOURCE = "the source note\n"; + +interface Fixture { + readonly workspace: string; +} + +function useFixture(): Operation { + return resource(function* (provide) { + const root = yield* until(realpath(yield* until(mkdtemp(join(tmpdir(), "prm-test-"))))); + yield* ensure(() => rm(root, { recursive: true, force: true })); + const workspace = join(root, "workspace"); + yield* until(mkdir(workspace)); + yield* writeTextFile(join(workspace, "source.txt"), SOURCE); + yield* provide({ workspace }); + }); +} + +/** + * The host this document runs under: a working directory, a real provider, and + * no restriction at all. + * + * Installed outside the execution, where an entrypoint installs it, so every + * restriction below is something a *region inside the document* established. + */ +function* useHost(fixture: Fixture): Operation { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd() { + return fixture.workspace; + }, + }, + { at: "min" }, + ); + yield* useHostFiles(); +} + +/** + * The ordinary ceiling, with the write table admitted. + * + * The write form is admitted deliberately: a generated write that preflight + * refused would prove nothing about live permission, because the fragment + * would never reach an operation. These rows are about the refusal that + * happens at the provider, so the fragment has to be allowed to try. + */ +function ceiling(): ExecutionInstallation { + return { + evaluation: { + read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], + write: [ + fileWriteEntry(), + directoryEntry({ origin: "test://host", key: "Dir", revision: "1" }, "Dir"), + fileDeleteEntry(), + ], + }, + }; +} + +/** The components these documents are written with. */ +function* useRegions(): Operation { + yield* registerComponents([ + { + name: "ReadOnly", + origin: "test://regions", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + // The ordinary restriction, exactly as the contract documents it: + // refuse the class, delegate everything else unchanged. + yield* Permissions.around({ + files: ([operation], next) => (operation === "write" ? false : next(operation)), + }); + return yield* content(); + }, + }, + { + name: "Permitting", + origin: "test://regions", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + // What a scope that wants more than it was given would write. It + // installs at the default order, inside the region above. + yield* Permissions.around({ files: () => true }); + return yield* content(); + }, + }, + { + name: "DirectRead", + origin: "test://regions", + props: { type: "object", properties: { path: { type: "string" } }, required: ["path"] }, + *fn(props): Operation { + const read = yield* readFileText({ cwd: yield* cwd(), path: String(props.path) }); + return read.ok ? read.value : "direct read refused\n"; + }, + }, + { + name: "DirectWrite", + origin: "test://regions", + props: { + type: "object", + properties: { path: { type: "string" }, text: { type: "string" } }, + required: ["path", "text"], + }, + *fn(props): Operation { + const failure = yield* writeFileText({ + cwd: yield* cwd(), + path: String(props.path), + content: String(props.text), + }); + return failure === undefined ? "direct write committed\n" : "direct write refused\n"; + }, + }, + ]); +} + +function run(fixture: Fixture, source: string): Operation { + return scoped(function* () { + yield* useHost(fixture); + yield* useRegions(); + return yield* collect( + yield* executeInstalled( + { ...retainedSource(ROOT_PATH, source), stream: new InMemoryStream() }, + [ceiling()], + ), + ); + }); +} + +/** + * What one run refused with. + * + * Public `` fails on every refusal — which is its contract, and the + * reason a generated refusal is a separate row from an authored one: an + * authored `` prints its own failure and the document carries on, while a + * fragment's failure ends the element that admitted it. + */ +function* refusal(fixture: Fixture, source: string): Operation { + try { + yield* run(fixture, source); + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +/** One request the transport actually accepted. */ +interface Accepted { + readonly method: string; + readonly path: string; +} + +interface Loopback { + readonly origin: string; + readonly accepted: Accepted[]; +} + +/** + * A real server on a port the host chose. + * + * A stub `API.Fetch` would prove nothing here: the terminal is what asks + * `Permissions`, so a test that replaced it would be asserting about a + * transport that never reaches the question. What a refusal has to show is that + * no request was *sent*, and only something listening can say that. + */ +function useLoopback(): Operation { + return resource(function* (provide) { + const accepted: Accepted[] = []; + const server: Server = createServer((request: IncomingMessage, response: ServerResponse) => { + accepted.push({ method: request.method ?? "", path: request.url ?? "" }); + response.writeHead(200, { "content-type": "text/plain" }); + response.end("from the network"); + }); + const listening = withResolvers(); + let onError: ((error: Error) => void) | undefined; + yield* ensure(() => { + if (onError) { + server.off("error", onError); + } + }); + onError = (error: Error) => listening.reject(error); + server.on("error", onError); + server.listen(0, "127.0.0.1", () => listening.resolve()); + yield* listening.operation; + yield* ensure(function* () { + const closed = withResolvers(); + server.closeAllConnections(); + server.close(() => closed.resolve()); + yield* closed.operation; + }); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("the loopback server reported no TCP address"); + } + yield* provide({ origin: `http://127.0.0.1:${address.port}`, accepted }); + }); +} + +describe("Tier PRM — authored, generated and direct operations under one restriction", () => { + it("PRM1: every class of read is answered inside the region", function* () { + const fixture = yield* useFixture(); + const output = String( + yield* run( + fixture, + [ + "", + "", + // Authored, rendering what it read rather than binding it, so the + // answer is visible in the document. + '', + // Generated, through public `` under the admitted tables. + `\\n'} />`, + // Direct, from a component that reaches no component at all. + '', + "", + "", + "", + ].join("\n"), + ), + ); + + // Three callers, three answers, one provider and one permitted class. + expect(output.split("the source note").length - 1).toBe(3); + expect(output).not.toContain("direct read refused"); + expect(output).not.toContain("ERROR"); + }); + + it("PRM1b: an authored write is refused, and the sibling outside the region is not", function* () { + const fixture = yield* useFixture(); + const output = String( + yield* run( + fixture, + [ + "", + "", + 'written by the document', + "", + "", + "", + 'written outside the region', + "", + ].join("\n"), + ), + ); + + expect(output).toContain("permission denied"); + expect(yield* exists(join(fixture.workspace, "authored.txt"))).toBe(false); + // The restriction belonged to the region rather than to the run, and + // leaving it restored the host's own allowance. + expect(yield* readTextFile(join(fixture.workspace, "after.txt"))).toBe( + "written outside the region", + ); + }); + + it("PRM1c: a direct contextual write is refused on the same terms", function* () { + const fixture = yield* useFixture(); + const output = String( + yield* run( + fixture, + [ + "", + "", + '', + "", + "", + "", + '', + "", + ].join("\n"), + ), + ); + + // A component calling the contextual operation itself names no component + // and gets no different answer: the restriction is the provider's to obey, + // not a component's to apply. + expect(output).toContain("direct write refused"); + expect(output).toContain("direct write committed"); + expect(yield* exists(join(fixture.workspace, "direct.txt"))).toBe(false); + expect(yield* readTextFile(join(fixture.workspace, "direct-after.txt"))).toBe( + "written outside", + ); + }); + + it("PRM1d: a generated write is refused by the same restriction, and fails its element", function* () { + const fixture = yield* useFixture(); + const failed = yield* refusal( + fixture, + [ + "", + "", + `written by a fragment\\n'} />`, + "", + "", + "", + ].join("\n"), + ); + + // The write table was admitted, so preflight let the fragment try: what + // refused it is the live restriction at the provider, which is the whole + // reason the form is admitted here. + expect(failed).toContain("permission denied"); + expect(yield* exists(join(fixture.workspace, "generated.txt"))).toBe(false); + }); + + it("PRM2: a descendant answering true cannot defeat the enclosing refusal", function* () { + const fixture = yield* useFixture(); + const output = String( + yield* run( + fixture, + [ + "", + "", + "", + "", + '', + "", + "", + "", + "", + "", + ].join("\n"), + ), + ); + + // The descendant installed at the default order and answered `true`. The + // enclosing refusal is never asked to delegate, so the answer the provider + // reads is still no. + expect(output).toContain("direct write refused"); + expect(yield* exists(join(fixture.workspace, "widened.txt"))).toBe(false); + }); + + it("PRM3: a paired write reads its children before the write is refused", function* () { + const fixture = yield* useFixture(); + const crossed: string[] = []; + + const output = String( + yield* scoped(function* () { + yield* useHost(fixture); + yield* useRegions(); + yield* API.Files.around({ + *readTextFile([input], next) { + crossed.push(`read ${input.path}`); + return yield* next(input); + }, + *writeTextFile([input], next) { + crossed.push(`write ${input.path}`); + return yield* next(input); + }, + }); + return yield* collect( + yield* executeInstalled( + { + ...retainedSource( + ROOT_PATH, + [ + "", + "", + '', + "", + "", + "", + ].join("\n"), + ), + stream: new InMemoryStream(), + }, + [ceiling()], + ), + ); + }), + ); + + // The authored order is the contract, and permission did not move it: the + // pure path check runs before the children, the children expand — so the + // read inside really happened — and authorization stands at the write the + // provider is eventually asked to perform. The read is not in the output + // because the write consumed it, which is why this row observes the + // operations rather than reading the rendering. + expect(crossed).toEqual(["read source.txt", "write copy.txt"]); + expect(output).toContain("permission denied"); + expect(yield* exists(join(fixture.workspace, "copy.txt"))).toBe(false); + }); + + it("PRM4: a generated fragment's operations cross the document's own middleware", function* () { + const fixture = yield* useFixture(); + const crossed: string[] = []; + + const output = String( + yield* scoped(function* () { + yield* useHost(fixture); + yield* useRegions(); + // Installed where a document's own middleware is, and both observing + // and transforming: what the fragment asked for is not what the + // provider performs. + yield* API.Files.around({ + *readTextFile([input], next) { + crossed.push(`read ${input.path}`); + return yield* next(input); + }, + *writeTextFile([input], next) { + crossed.push(`write ${input.path}`); + return yield* next({ ...input, content: `transformed: ${input.content}` }); + }, + }); + return yield* collect( + yield* executeInstalled( + { + ...retainedSource( + ROOT_PATH, + `\\n` + + `from the fragment\\n'} />\n`, + ), + stream: new InMemoryStream(), + }, + [ceiling()], + ), + ); + }), + ); + + expect(output).not.toContain("could not"); + // One middleware, both payloads, in the fragment's own order. + expect(crossed).toEqual(["read source.txt", "write made.txt"]); + // And the transformation is what landed, which is the half an + // observing-only assertion would miss. + expect(yield* readTextFile(join(fixture.workspace, "made.txt"))).toBe( + "transformed: from the fragment", + ); + }); + + it("PRM6: a generated request performs its entry's admitted request, and the transport sees it", function* () { + const fixture = yield* useFixture(); + const output = String( + yield* scoped(function* () { + const server = yield* useLoopback(); + yield* useHost(fixture); + yield* useRegions(); + const admitted = { url: `${server.origin}/admitted`, method: "GET", headers: {} }; + const rendered = yield* collect( + yield* executeInstalled( + { + ...retainedSource( + ROOT_PATH, + `\\n` + + `\\n'} />\n`, + ), + stream: new InMemoryStream(), + }, + [ + { + evaluation: { + read: [ + fileReadEntry(), + globReadEntry(), + syntaxReadEntry(), + fetchEntry([admitted]), + ], + }, + }, + ], + ), + ); + // Read inside the scope, before the server is torn down. + expect(server.accepted).toEqual([{ method: "GET", path: "/admitted" }]); + return rendered; + }), + ); + + expect(output).toContain("from the network"); + }); + + it("PRM7: a transformation off the entry's ceiling is refused at the transport", function* () { + const fixture = yield* useFixture(); + let accepted: readonly Accepted[] = []; + + const failed = yield* scoped(function* () { + const server = yield* useLoopback(); + yield* useHost(fixture); + yield* useRegions(); + const admitted = { url: `${server.origin}/admitted`, method: "GET", headers: {} }; + // A handler composed around the contextual chain while the document runs, + // rewriting the destination to somewhere the entry never admitted. + yield* API.Fetch.around({ + *fetch([input, init], next) { + return yield* next(input.replace("/admitted", "/elsewhere"), init); + }, + }); + let message = ""; + try { + yield* collect( + yield* executeInstalled( + { + ...retainedSource( + ROOT_PATH, + `\\n` + + `\\n'} />\n`, + ), + stream: new InMemoryStream(), + }, + [ + { + evaluation: { + read: [ + fileReadEntry(), + globReadEntry(), + syntaxReadEntry(), + fetchEntry([admitted]), + ], + }, + }, + ], + ), + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + accepted = [...server.accepted]; + return message; + }); + + // The ceiling is in force where the request is actually sent, not only + // where the component read its props — and a refused request is not a + // request: the transport accepted nothing at all. + expect(failed).toContain("not permitted in this scope"); + expect(accepted).toEqual([]); + }); + + it("PRM5: a generated Dir scopes what follows it and restores the directory", function* () { + const fixture = yield* useFixture(); + + yield* run( + fixture, + `\\n` + + `in the nested directory\\n\\n` + + `beside it\\n'} />\n`, + ); + + // The directory the fragment made, the file it wrote inside it, and — the + // discriminating half — the file written after the directory closed, which + // is in the workspace rather than in the nested directory. The working + // directory a generated `` installs is the ordinary invocation-owned + // one, so leaving the element takes it away again. + expect(yield* readTextFile(join(fixture.workspace, "nested", "inside.txt"))).toBe( + "in the nested directory", + ); + expect(yield* readTextFile(join(fixture.workspace, "beside.txt"))).toBe("beside it"); + expect(yield* exists(join(fixture.workspace, "nested", "beside.txt"))).toBe(false); + }); +}); diff --git a/packages/core/tests/support/fragment-files.ts b/packages/core/tests/support/fragment-files.ts index 265733195..ab6a280cf 100644 --- a/packages/core/tests/support/fragment-files.ts +++ b/packages/core/tests/support/fragment-files.ts @@ -1,26 +1,50 @@ /** - * A recording stand-in for the private filesystem operations a host hands an - * evaluation profile. + * A recording `API.Files` provider, installed the way a host installs one. * - * Deliberately *not* a Files provider. It is never installed into `API.Files`, - * never composed around, and reachable only through the profile it is passed - * to — which is the point: a row that reads a file through this one has proved - * the read went through the captured operation, because there is no other way - * to reach this object. + * It used to be the opposite: a stand-in for the private operations a host + * handed an evaluation profile, deliberately never installed, so a read that + * appeared in its log had proved the read went through the captured operation + * because there was no other way to reach the object. That premise is gone with + * the transport it described. An admitted fragment now runs the ordinary + * component, which resolves `API.Files` where it runs — so what a log entry + * here proves is that a generated operation crossed the *same* provider an + * authored one does, which is the property that replaced it. + * + * It answers the real provider contract, not a convenient subset: valid + * `FilesError` data for a refusal and a validated `FileWriteSuccess` for a + * write. A fixture that answered `Err(new Error(...))` would be testing against + * a provider the engine is entitled to call broken. + * + * It asks `Permissions` too, as the first thing each operation does. A provider + * that performs the operation authorizes the operation, and this one stands in + * for one that does — a fixture that skipped the question would make every + * policy row that runs against it pass whatever the policy said. */ import type { Operation, Result } from "effection"; import { Err, Ok } from "effection"; +import { + API, + FilesOperationDeniedError, + filesFailure, + fileWriteFailure, + fileWriteSuccess, + Permissions, +} from "@executablemd/runtime"; import type { - FragmentFileAccess, - FragmentPath, - FragmentSearch, - FragmentWrite, -} from "../../host.ts"; + FilePathInput, + FilesHandler, + FileWriteInput, + FileWriteSuccess, + GlobInput, +} from "@executablemd/runtime"; + +/** The logical directory a fixture run resolves its relative paths against. */ +export const FIXTURE_ROOT = "/workspace"; -/** Every operation one fragment performed, in order, as `verb path`. */ -export interface RecordedFiles extends FragmentFileAccess { +/** Every operation one run performed, in order, as `verb path`. */ +export interface RecordedFiles extends FilesHandler { readonly performed: string[]; readonly entries: Map; } @@ -28,8 +52,8 @@ export interface RecordedFiles extends FragmentFileAccess { /** * One recorder, seeded with whatever files a case says already exist. * - * `workingDirectory` answers a fixed logical root, so a row asserting on a - * recorded path is asserting on what the fragment asked for rather than on + * Keyed by the authored path rather than the resolved one, so a row asserting + * on a recorded path is asserting on what the element asked for rather than on * wherever the test happened to run. */ export function recordedFiles( @@ -37,7 +61,7 @@ export function recordedFiles( options: { readonly hold?: (path: string) => Operation; /** What a search answers with, in place of matching the seeded entries. */ - readonly found?: (input: FragmentSearch) => Result; + readonly found?: (input: GlobInput) => Result; } = {}, ): RecordedFiles { const entries = new Map(Object.entries(seed)); @@ -46,23 +70,43 @@ export function recordedFiles( performed, entries, // deno-lint-ignore require-yield - *checkFilePath(input: FragmentPath): Operation> { + *checkFilePath(input: FilePathInput): Operation> { + // Pure, and deliberately unauthorized: it reaches nothing. performed.push(`check ${input.path}`); - return input.path.startsWith("..") ? Err(new Error("outside")) : Ok(undefined); + return input.path.startsWith("..") + ? Err( + filesFailure({ + operation: "check-file-path", + phase: "lexical", + reason: "lexical-escape", + }), + ) + : Ok(undefined); }, - *readTextFile(input: FragmentPath): Operation> { + *readTextFile(input: FilePathInput): Operation> { + if (!(yield* Permissions.operations.files("read"))) { + return Err( + filesFailure({ operation: "read", phase: "resolution", reason: "permission-denied" }), + ); + } performed.push(`read ${input.path}`); - // A row about cancelling work already inside the fragment holds here: - // the operation has begun and has not answered, which is the only state + // A row about cancelling work already inside a fragment holds here: the + // operation has begun and has not answered, which is the only state // where an admitted effect is in flight. if (options.hold !== undefined) { yield* options.hold(input.path); } const held = entries.get(input.path); - return held === undefined ? Err(new Error("absent")) : Ok(held); + return held === undefined + ? Err(filesFailure({ operation: "read", phase: "target", reason: "missing" })) + : Ok(held); }, - // deno-lint-ignore require-yield - *globFiles(input: FragmentSearch): Operation> { + *globFiles(input: GlobInput): Operation> { + if (!(yield* Permissions.operations.files("read"))) { + return Err( + filesFailure({ operation: "glob", phase: "target", reason: "permission-denied" }), + ); + } performed.push(`glob ${input.include.join(",")}`); if (options.found !== undefined) { return options.found(input); @@ -70,33 +114,105 @@ export function recordedFiles( // The seeded entries a pattern selects, held to the same answer an // ordinary provider gives: sorted, deduplicated and relative. Matching is // deliberately literal — a case states the paths it means — because what - // a row here proves is that the search went through this operation, not - // that a dialect was reimplemented. + // a row here proves is that the search crossed this provider, not that a + // dialect was reimplemented. const matched = [...entries.keys()].filter( (path) => input.include.includes(path) && !input.exclude.includes(path), ); return Ok([...new Set(matched)].sort()); }, - // deno-lint-ignore require-yield - *writeTextFile(input: FragmentWrite): Operation> { + *writeTextFile(input: FileWriteInput): Operation> { + if (!(yield* Permissions.operations.files("write"))) { + return Err(fileWriteFailure({ phase: "resolution", reason: "permission-denied" })); + } performed.push(`write ${input.path}`); + if (input.path.startsWith("..")) { + return Err(fileWriteFailure({ phase: "lexical", reason: "lexical-escape" })); + } entries.set(input.path, input.content); - return Ok(undefined); + return Ok(fileWriteSuccess("host-committed")); }, - // deno-lint-ignore require-yield - *deleteFile(input: FragmentPath): Operation> { + *deleteFile(input: FilePathInput): Operation> { + if (!(yield* Permissions.operations.files("write"))) { + return Err( + filesFailure({ operation: "delete", phase: "resolution", reason: "permission-denied" }), + ); + } performed.push(`delete ${input.path}`); entries.delete(input.path); return Ok(undefined); }, - // deno-lint-ignore require-yield - *ensureDirectory(input: FragmentPath): Operation> { + *ensureDirectory(input: FilePathInput): Operation> { + if (!(yield* Permissions.operations.files("write"))) { + return Err( + filesFailure({ + operation: "ensure-directory", + phase: "resolution", + reason: "permission-denied", + }), + ); + } performed.push(`ensure ${input.path}`); return Ok(undefined); }, + /** + * Refused, and refused the way a provider refuses an operation it does not + * offer. + * + * The recorder has no temporary directories to hand out, and answering one + * with a host path would put a real directory behind a fixture nothing + * cleans up. A provider is entitled to deny this one operation, and the + * documented denial is how it says so. + */ // deno-lint-ignore require-yield - *workingDirectory(): Operation { - return "/workspace"; + *temporaryDirectory(): Operation> { + throw new FilesOperationDeniedError("temporary-directory"); }, }; } + +/** + * Install one recorder as this execution's document filesystem. + * + * Beneath ordinary middleware, like every host provider, and beside a + * contextual working directory of its own: the authored paths a case writes are + * relative, and resolving them against the directory the test process happens + * to be in would make a recorded path depend on where the suite ran. + */ +export function* useRecordedFiles(files: RecordedFiles): Operation { + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return FIXTURE_ROOT; + }, + }, + { at: "min" }, + ); + yield* API.Files.around( + { + *checkFilePath([input]) { + return yield* files.checkFilePath(input); + }, + *readTextFile([input]) { + return yield* files.readTextFile(input); + }, + *writeTextFile([input]) { + return yield* files.writeTextFile(input); + }, + *deleteFile([input]) { + return yield* files.deleteFile(input); + }, + *ensureDirectory([input]) { + return yield* files.ensureDirectory(input); + }, + *globFiles([input]) { + return yield* files.globFiles(input); + }, + *temporaryDirectory() { + return yield* files.temporaryDirectory(); + }, + }, + { at: "min" }, + ); +} diff --git a/packages/runtime/api.ts b/packages/runtime/api.ts index 8333cf9b0..8e4e1404b 100644 --- a/packages/runtime/api.ts +++ b/packages/runtime/api.ts @@ -16,8 +16,17 @@ * // render the detail a quiet run leaves out * } * ``` + * + * `Permissions` is here for the same reason: a scope that permits less than + * its host does installs middleware around it, which is configuration of the + * run rather than a filesystem or a transport of its own. `FetchInit` comes + * with it, so a restriction can be typed without importing the host surface + * the request is eventually performed by. */ export { Config, timeout, timeoutExec, timeoutFetch, verbose } from "./config.ts"; export type { ConfigApi } from "./config.ts"; export { asDuration, durationError, parseDuration } from "./duration.ts"; +export { FETCH_NOT_PERMITTED_MESSAGE, Permissions } from "./permissions.ts"; +export type { PermissionsApi } from "./permissions.ts"; +export type { FetchInit } from "./apis.ts"; diff --git a/packages/runtime/apis.ts b/packages/runtime/apis.ts index c971d3064..6854680d0 100644 --- a/packages/runtime/apis.ts +++ b/packages/runtime/apis.ts @@ -91,6 +91,7 @@ import { race, scoped, sleep, until } from "effection"; import type { Operation } from "effection"; import { timeoutFetch as contextualFetchTimeout } from "./config.ts"; import { Files } from "./files.ts"; +import { FETCH_NOT_PERMITTED_MESSAGE, Permissions } from "./permissions.ts"; import { Service } from "./service.ts"; /** @@ -690,9 +691,19 @@ export const API: { * * Default implementation uses `@effectionx/fetch`. * Cancellation aborts the request via Effection scope teardown. + * + * This is also where a request is authorized, because it is the last place + * the request is still only a request: every `API.Fetch` middleware has + * already had its turn, so what `Permissions` is asked about is the + * destination, method and headers the transport would actually send, not the + * ones the caller first wrote. A refusal throws here, before transport + * starts, so no request is made and there is no response to bind. */ Fetch: createApi("runtime.fetch", { *fetch(input: string, init?: FetchInit): Operation { + if (!(yield* Permissions.operations.fetch(input, init))) { + throw new Error(FETCH_NOT_PERMITTED_MESSAGE); + } const timeout = init?.timeout ?? (yield* contextualFetchTimeout); const request = effectionFetch(input, { method: init?.method, diff --git a/packages/runtime/files.ts b/packages/runtime/files.ts index 3f01b70f3..fdaf87a11 100644 --- a/packages/runtime/files.ts +++ b/packages/runtime/files.ts @@ -25,6 +25,22 @@ * same admission from the same authored path. A check that was skipped, * replaced, or answered by another provider therefore authorizes nothing. * + * ## Who asks whether it is permitted + * + * The provider does, as the last thing before its first I/O. `Permissions` + * mediates these operations, but this Api does not consult it: a check made + * here would be made for an operation that has not been transformed yet, and + * every middleware between a caller and the provider is entitled to change + * which path or pattern is actually used. So the implementation that performs + * the operation authorizes the operation it is really about to perform — + * including a replacement provider, for which this is part of the contract + * rather than something inherited. + * + * A refusal is an ordinary failure, reported like any other: `permission-denied` + * in the phase the provider stopped at, with no I/O performed and no change to + * a target. A provider that answers synthetically, performing no I/O at all, + * has nothing to authorize and nothing to report. + * * ## Two kinds of failure * * An ordinary filesystem condition — missing, a directory, permission denied, diff --git a/packages/runtime/host-files.ts b/packages/runtime/host-files.ts index 52c2faac4..03a90812c 100644 --- a/packages/runtime/host-files.ts +++ b/packages/runtime/host-files.ts @@ -54,6 +54,28 @@ * single commit point, and nothing is acquired around it, so cancellation * before it changes nothing and there is no cleanup to fail. * + * ## Permission + * + * Every operation that touches the filesystem asks `Permissions` first, and + * asks it here rather than in `useHostFiles`'s forwarding middleware, so a + * caller holding this handler directly is under the same restriction as a + * document reaching it through the Api. + * + * Where the question goes is as load-bearing as the answer. It stands after + * the pure lexical check — a path that was never admissible is not a thing to + * ask permission for — and before the first I/O of any kind, which for most + * operations means before resolution: `realpath` and `stat` are already reads + * of the caller's filesystem, and a refusal that had resolved the path first + * would have read what it was about to refuse. So a denied operation leaves no + * trace at all: nothing resolved, nothing stat'ed, nothing traversed, nothing + * created, and a target reported unchanged. + * + * The class asked for is the one the *request* belongs to, not the one the + * filesystem turns out to need. Ensuring a directory that already exists and + * deleting a path that names nothing are both `write`, because deciding + * otherwise would mean probing the filesystem to find out which question to + * ask — which is the read a refusal is supposed to prevent. + * * ## What crosses the boundary * * Nothing from a caught platform error. An errno code *selects* a @@ -73,6 +95,7 @@ import { tmpdir } from "node:os"; import { FsApi, rm } from "@effectionx/fs"; import { API } from "./apis.ts"; import { fileWriteFailure, fileWriteSuccess, filesFailure, FilesInvariantError } from "./files.ts"; +import { Permissions } from "./permissions.ts"; import type { FilePathInput, FilesHandler, @@ -382,6 +405,10 @@ export function hostFilesHandler(options: HostFilesOptions = {}): FilesHandler { return nonWriteFailure("read", "lexical", lexical); } + if (!(yield* Permissions.operations.files("read"))) { + return nonWriteFailure("read", "resolution", "permission-denied"); + } + const target = yield* destination(input); if ("reason" in target) { return nonWriteFailure("read", "resolution", target.reason); @@ -436,6 +463,10 @@ export function hostFilesHandler(options: HostFilesOptions = {}): FilesHandler { return writeFailure({ phase: "lexical", reason: lexical }); } + if (!(yield* Permissions.operations.files("write"))) { + return writeFailure({ phase: "resolution", reason: "permission-denied" }); + } + const target = yield* destination(input); if ("reason" in target) { return writeFailure({ phase: "resolution", reason: target.reason }); @@ -534,6 +565,10 @@ export function hostFilesHandler(options: HostFilesOptions = {}): FilesHandler { return nonWriteFailure("delete", "lexical", lexical); } + if (!(yield* Permissions.operations.files("write"))) { + return nonWriteFailure("delete", "resolution", "permission-denied"); + } + const target = yield* removalDestination(input); if ("reason" in target) { return nonWriteFailure("delete", "resolution", target.reason); @@ -608,6 +643,10 @@ export function hostFilesHandler(options: HostFilesOptions = {}): FilesHandler { return nonWriteFailure("ensure-directory", "lexical", "empty-path"); } + if (!(yield* Permissions.operations.files("write"))) { + return nonWriteFailure("ensure-directory", "resolution", "permission-denied"); + } + const target = yield* directoryDestination(input); if ("reason" in target) { return nonWriteFailure("ensure-directory", "resolution", target.reason); @@ -645,6 +684,13 @@ export function hostFilesHandler(options: HostFilesOptions = {}): FilesHandler { * the same way on every host. */ function* globFiles(input: GlobInput): Operation> { + // `target` rather than `resolution`: a glob resolves no authored path, and + // the first filesystem it touches is this stat of the directory it would + // walk. + if (!(yield* Permissions.operations.files("read"))) { + return nonWriteFailure("glob", "target", "permission-denied"); + } + try { const info = yield* API.Fs.operations.stat(input.cwd); if (!info.exists) { @@ -692,6 +738,11 @@ export function hostFilesHandler(options: HostFilesOptions = {}): FilesHandler { */ function temporaryDirectory(): Operation> { return resource(function* (provide) { + if (!(yield* Permissions.operations.files("write"))) { + yield* provide(nonWriteFailure("temporary-directory", "acquire", "permission-denied")); + return; + } + let created: string; try { // oxlint-disable-next-line local/no-sync-filesystem diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index d57bccbf3..00c65bcfb 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -5,7 +5,7 @@ * `API` is available for middleware (`.around()`). * For normal calls, import operations directly. * - * Seven domain APIs: + * The domain APIs: * - `API.Process` — subprocess execution (`exec`) * - `API.Fs` — the low-level host filesystem (`readTextFile`, `writeTextFile`, * `stat`, `lstat`, `readDirectory`, `glob`, `realpath`, `ensureDir`, `rename`, @@ -21,6 +21,8 @@ * (`reserveTerminal`, `flushOutput`, `notifyTerminal`, `nativeLaunch`) * - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`, * `verbose`) + * - `Permissions` — whether this scope may perform the semantic operation + * `API.Files` or `API.Fetch` is about to carry out (`files`, `fetch`) * * See `apis.ts` for architecture rationale. * See `@executablemd/runtime/test` for composable test stubs. @@ -83,6 +85,8 @@ export type { export { Config, timeout, timeoutExec, timeoutFetch, verbose } from "./config.ts"; export type { ConfigApi } from "./config.ts"; export { asDuration, durationError, parseDuration } from "./duration.ts"; +export { FETCH_NOT_PERMITTED_MESSAGE, Permissions } from "./permissions.ts"; +export type { PermissionsApi } from "./permissions.ts"; export type { ProcessExecOptions, ProcessOutcome } from "./apis.ts"; export { asFilesFatal, diff --git a/packages/runtime/permissions.ts b/packages/runtime/permissions.ts new file mode 100644 index 000000000..a5ef5f684 --- /dev/null +++ b/packages/runtime/permissions.ts @@ -0,0 +1,107 @@ +/** + * Permissions Api — whether the scope asking is allowed the operation it is + * about to perform. + * + * Two members, one for the document filesystem and one for HTTP, and both + * answer `true` until something says otherwise. A host that permits everything + * installs nothing; a scope that permits less installs middleware: + * + * ```typescript + * yield* Permissions.around({ + * files: ([operation], next) => (operation === "write" ? false : next(operation)), + * }); + * ``` + * + * ## Where the answer is asked + * + * At the implementation that performs the operation, immediately before its + * first I/O, and nowhere else. The provider is what knows which class of + * operation it is about to carry out, so it is what asks — a check placed at a + * caller would be a check some other caller reaches the same filesystem + * without. One consequence worth stating: a request is authorized *after* the + * contextual middleware that may transform it, so what is permitted is what + * would actually be performed rather than what was first asked for. + * + * ## What composition means + * + * A restriction installs at the default `max` order and composes like any other + * middleware: it returns `false` to refuse, and otherwise delegates the + * unchanged arguments. Delegation is what makes restrictions intersect — the + * answer is the whole chain's, so a scope nested inside a read-only region + * cannot permit a write by answering `true`, because the enclosing refusal is + * never asked to delegate. A sibling outside that region is unaffected, since + * what a scope sees is the chain its own ancestors installed. + * + * Do not implement a restriction by returning `true` early. Doing so answers + * for every enclosing scope as well, which is the one thing a restriction must + * not be able to do. + * + * A decision is synchronous. There is nothing to wait for in reading a policy + * the installing scope already holds, and the answer stands immediately before + * an effect — so it is a plain `boolean`, not an operation, and a chain cannot + * be interleaved with other work between the answer and the I/O it authorizes. + * + * ## What this is, and is not + * + * It mediates the semantic operations named below: document filesystem access + * through `API.Files`, and HTTP through `API.Fetch`. It is not a sandbox. The + * low-level host filesystem (`API.Fs`), subprocesses, Git host operations and + * every other native reach are not mediated by implication, and nothing here + * confines code that calls them directly. + * + * It is also not a capability: a `true` answer supplies nothing. It says only + * that the scope asking is not forbidden, and the operation still needs a + * provider that can perform it. A replacement provider that performs real I/O + * authorizes its own operation for the same reason the host one does; one that + * answers synthetically, performing no I/O, has nothing to authorize. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { FetchInit } from "./apis.ts"; + +export interface PermissionsApi { + /** + * Whether this request may be sent. + * + * Asked with the request as it would go out — after every transformation + * `API.Fetch` middleware made to it — so a restriction reads the actual + * destination, method and headers rather than the caller's first draft. + */ + fetch(input: string, init?: FetchInit): boolean; + /** + * Whether this class of document filesystem operation may be performed. + * + * Which class a request belongs to is decided by what the caller asked for, + * not by what the filesystem turns out to hold: ensuring a directory that + * already exists and deleting a path that names nothing are both `write`, + * because a scope that may not change the filesystem may not ask to. + */ + files(operation: "read" | "write"): boolean; +} + +/** + * The permission Api. + * + * Both defaults permit. An uninstalled Permissions is the ordinary case — most + * runs are a person asking their own tools to touch their own files — and the + * boundary that matters is already the provider's: an absent `API.Files` + * provider reaches no filesystem at all, whatever this answers. + */ +export const Permissions: Api = createApi( + "executablemd.runtime.permissions", + { + fetch: () => true, + files: () => true, + }, +); + +/** + * What a refused request fails with. + * + * One fixed sentence. A permission refusal is reported where the request was + * made, and the request itself is what the refusing scope was keeping out of + * the message: a URL carries credentials in its query, and headers carry + * tokens. The scope that installed the restriction already knows what it + * refused. + */ +export const FETCH_NOT_PERMITTED_MESSAGE = "The HTTP request is not permitted in this scope."; diff --git a/packages/runtime/tests/fetch.test.ts b/packages/runtime/tests/fetch.test.ts index 6227d4985..72eacd302 100644 --- a/packages/runtime/tests/fetch.test.ts +++ b/packages/runtime/tests/fetch.test.ts @@ -23,6 +23,7 @@ import type { IncomingMessage, Server, ServerResponse } from "node:http"; import { API, fetch } from "../apis.ts"; import type { RuntimeFetchResponse } from "../apis.ts"; import { Config } from "../config.ts"; +import { FETCH_NOT_PERMITTED_MESSAGE, Permissions } from "../permissions.ts"; interface Loopback { /** Where the server is listening. */ @@ -377,6 +378,96 @@ describe("Tier FR — the chainable calling shape", () => { expect(failure?.message).toContain("timed out after 60ms"); }); + + it("FR13: the request a restriction answers for is the one that would be sent", function* () { + const server = yield* useLoopback(respond([], "body")); + const asked: Array<{ input: string; method?: string; routed?: string }> = []; + + yield* scoped(function* () { + // A host that routes every request through one prefix and marks it. + yield* API.Fetch.around({ + *fetch([input, init], next): Operation { + const url = new URL(input); + return yield* next(`${server.origin}/routed${url.pathname}`, { + ...init, + method: init?.method ?? "GET", + headers: { ...init?.headers, "x-routed": "yes" }, + }); + }, + }); + + yield* Permissions.around({ + fetch: ([input, init], next) => { + asked.push({ input, method: init?.method, routed: init?.headers?.["x-routed"] }); + return new URL(input).pathname.startsWith("/routed/public") ? next(input, init) : false; + }, + }); + + const allowed = yield* fetch(`${server.origin}/public/doc`); + expect(allowed.status).toBe(200); + expect(yield* allowed.text()).toBe("body"); + + // The direct Api call and the public chain are the same route, so one + // restriction covers both. + for (const attempt of [ + function* (): Operation { + return yield* API.Fetch.operations.fetch(`${server.origin}/private/doc`); + }, + function* (): Operation { + return yield* fetch(`${server.origin}/private/doc`).text(); + }, + ]) { + expect((yield* thrown(attempt))?.message).toBe(FETCH_NOT_PERMITTED_MESSAGE); + } + }); + + // What the restriction read is the routed URL and the header the host + // added, never the spelling the caller wrote: the question is asked where + // the request has stopped changing. + expect(asked).toEqual([ + { input: `${server.origin}/routed/public/doc`, method: "GET", routed: "yes" }, + { input: `${server.origin}/routed/private/doc`, method: "GET", routed: "yes" }, + { input: `${server.origin}/routed/private/doc`, method: "GET", routed: "yes" }, + ]); + // Two refusals, and the transport saw neither of them. + expect(server.requests.map((request) => request.path)).toEqual(["/routed/public/doc"]); + }); + + it("FR14: a synthetic answer asks nothing and reaches no transport", function* () { + const server = yield* useLoopback(respond([], "live")); + let asked = 0; + + yield* scoped(function* () { + yield* Permissions.around({ + fetch: () => { + asked += 1; + return false; + }, + }); + + // Answers from what it already holds, delegating nothing. There is no + // request to permit, because there is no request. + yield* API.Fetch.around({ + // deno-lint-ignore require-yield + *fetch(): Operation { + return { + status: 200, + headers: { get: () => null, entries: () => [] }, + *text() { + return "remembered"; + }, + }; + }, + }); + + const answered = yield* fetch(`${server.origin}/remembered`); + expect(answered.status).toBe(200); + expect(yield* answered.text()).toBe("remembered"); + }); + + expect(asked).toBe(0); + expect(server.requests).toEqual([]); + }); /** * The listener's error observer belongs to the loopback resource, not to the * first error it sees. The count is read after the resource has been torn diff --git a/packages/runtime/tests/host-files.test.ts b/packages/runtime/tests/host-files.test.ts index 9c5f47171..c6b40709e 100644 --- a/packages/runtime/tests/host-files.test.ts +++ b/packages/runtime/tests/host-files.test.ts @@ -40,6 +40,7 @@ import { join } from "node:path"; import process from "node:process"; import { API } from "../apis.ts"; import { hostFilesHandler, useHostFiles } from "../host-files.ts"; +import { Permissions } from "../permissions.ts"; import type { HostFilesEvent } from "../host-files.ts"; import { FILES_ERROR, @@ -1339,3 +1340,186 @@ describe("host Files — making a directory exist", () => { expect(yield* entries(checkout)).toEqual([".git", "generated", "tracked.txt"]); }); }); + +/** + * Permission, asked by the provider. + * + * These drive `hostFilesHandler()` directly rather than the installed Api, + * because that is the stronger claim: a host holding the handler — and the + * forwarding middleware `useHostFiles` installs is one — gets the same + * restriction as a document reaching it through `API.Files`. If the check lived + * in that middleware instead, every one of these would still pass through the + * Api and none of them would pass here. + */ +describe("host Files — permission", () => { + /** Every low-level reach a refused operation must not make. */ + function* census(touched: string[]): Operation { + yield* API.Fs.around({ + *stat([path], next) { + touched.push(`stat ${path}`); + return yield* next(path); + }, + *lstat([path], next) { + touched.push(`lstat ${path}`); + return yield* next(path); + }, + *realpath([path], next) { + touched.push(`realpath ${path}`); + return yield* next(path); + }, + *readTextFile([path], next) { + touched.push(`read ${path}`); + return yield* next(path); + }, + *writeTextFile([path, content], next) { + touched.push(`write ${path}`); + return yield* next(path, content); + }, + *remove([path, options], next) { + touched.push(`remove ${path}`); + return yield* next(path, options); + }, + *ensureDir([path], next) { + touched.push(`ensureDir ${path}`); + return yield* next(path); + }, + *rename([from, to], next) { + touched.push(`rename ${from}`); + return yield* next(from, to); + }, + *glob([search], next) { + touched.push(`glob ${search.root}`); + return yield* next(search); + }, + }); + } + + it("HF29: each operation asks its own class, and a refusal reaches no filesystem", function* () { + const fixture = yield* useFixture(); + yield* writeTextFile(join(fixture.workspace, "notes.md"), "untouched"); + const files = handler(); + const asked: string[] = []; + const touched: string[] = []; + + yield* scoped(function* () { + yield* census(touched); + yield* Permissions.around({ + files: ([operation]) => { + asked.push(operation); + return false; + }, + }); + + // Arithmetic, and permitted by nothing: a path that was never admissible + // is not a thing to ask permission for, and an admissible one is answered + // without reading anything. + const checked = yield* files.checkFilePath({ cwd: fixture.workspace, path: "notes.md" }); + expect(checked.ok).toBe(true); + expect(asked).toEqual([]); + + const read = yield* files.readTextFile({ cwd: fixture.workspace, path: "notes.md" }); + expect(parseFilesFailure(failed(read))).toEqual({ + type: FILES_ERROR, + operation: "read", + phase: "resolution", + reason: "permission-denied", + }); + + const written = yield* files.writeTextFile({ + cwd: fixture.workspace, + path: "notes.md", + content: "replacement", + }); + // The target claim is the part a document acts on: a write refused before + // any I/O says the file it named is the file it was. + expect(parseFileWriteFailure(failed(written))).toEqual({ + type: FILES_ERROR, + operation: "write", + phase: "resolution", + reason: "permission-denied", + target: "unchanged", + }); + + const deleted = yield* files.deleteFile({ cwd: fixture.workspace, path: "notes.md" }); + expect(parseFilesFailure(failed(deleted))).toEqual({ + type: FILES_ERROR, + operation: "delete", + phase: "resolution", + reason: "permission-denied", + }); + + const made = yield* files.ensureDirectory({ cwd: fixture.workspace, path: "generated" }); + expect(parseFilesFailure(failed(made))).toEqual({ + type: FILES_ERROR, + operation: "ensure-directory", + phase: "resolution", + reason: "permission-denied", + }); + + const globbed = yield* files.globFiles({ + cwd: fixture.workspace, + include: ["**/*.md"], + exclude: [], + }); + expect(parseFilesFailure(failed(globbed))).toEqual({ + type: FILES_ERROR, + operation: "glob", + phase: "target", + reason: "permission-denied", + }); + + const acquired = yield* files.temporaryDirectory(); + expect(parseFilesFailure(failed(acquired))).toEqual({ + type: FILES_ERROR, + operation: "temporary-directory", + phase: "acquire", + reason: "permission-denied", + }); + }); + + // Which class each operation belongs to, decided by the request rather than + // by what the filesystem holds: the deletion names a file that exists and + // the ensure names a directory that does not, and both are writes. + expect(asked).toEqual(["read", "write", "write", "write", "read", "write"]); + // Nothing resolved, nothing classified, nothing traversed. + expect(touched).toEqual([]); + expect(yield* readTextFile(join(fixture.workspace, "notes.md"))).toBe("untouched"); + expect(yield* entries(fixture.workspace)).toEqual(["notes.md"]); + }); + + it("HF30: a denied acquisition creates nothing under its root", function* () { + const { root } = yield* useFixture(); + const files = hostFilesHandler({ temporaryRoot: root }); + + yield* scoped(function* () { + yield* Permissions.around({ files: () => false }); + const acquired = yield* files.temporaryDirectory(); + expect(parseFilesFailure(failed(acquired))?.reason).toBe("permission-denied"); + }); + + // The one operation whose refusal cannot be proved by an `API.Fs` census: + // it creates with a synchronous native call, so what proves it created + // nothing is the namespace it would have created in. + expect((yield* entries(root)).filter((entry) => entry.startsWith("xmd-tempdir-"))).toEqual([]); + }); + + it("HF31: an acquired temporary is released although writes are refused", function* () { + const { root } = yield* useFixture(); + const files = hostFilesHandler({ temporaryRoot: root }); + let acquired = ""; + + yield* scoped(function* () { + acquired = value(yield* files.temporaryDirectory()); + expect(yield* exists(acquired)).toBe(true); + + // Narrowed after the acquisition, which is the case that matters: + // releasing what this scope already holds is the acquiring operation's + // own cleanup, not a new request the scope has to be permitted to make. + yield* Permissions.around({ files: () => false }); + expect(yield* Permissions.operations.files("write")).toBe(false); + }); + + expect(yield* exists(acquired)).toBe(false); + expect((yield* entries(root)).filter((entry) => entry.startsWith("xmd-tempdir-"))).toEqual([]); + }); +}); diff --git a/packages/runtime/tests/permissions.test.ts b/packages/runtime/tests/permissions.test.ts new file mode 100644 index 000000000..c3d9512d7 --- /dev/null +++ b/packages/runtime/tests/permissions.test.ts @@ -0,0 +1,134 @@ +/** + * Tier PM — the Permissions Api. + * + * What is asserted here is composition, and nothing else: no filesystem, no + * transport and no provider appears, because the rows that prove a provider + * asks this question live beside the provider. The question these answer is + * whether a `false` installed by one scope can be undone by another — which is + * the whole of what a restriction is for. + * + * Every restriction below is installed at the default order, with no `at` + * option, because that default is the convention a host writing one will + * follow and it is the ordering the guarantee depends on. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import { Permissions } from "../mod.ts"; +import { Permissions as configured } from "../api.ts"; +import type { FetchInit } from "../mod.ts"; + +/** One request, as a restriction saw it. */ +interface Seen { + input: string; + init: FetchInit | undefined; +} + +describe("Tier PM — Permissions", () => { + it("PM1: both members permit when nothing is installed", function* () { + expect(yield* Permissions.operations.files("read")).toBe(true); + expect(yield* Permissions.operations.files("write")).toBe(true); + expect( + yield* Permissions.operations.fetch("https://example.invalid/", { method: "POST" }), + ).toBe(true); + }); + + it("PM2: a restriction refuses its own class and reads the actual request", function* () { + const seen: Seen[] = []; + yield* scoped(function* () { + yield* Permissions.around({ + files: ([operation], next) => (operation === "write" ? false : next(operation)), + fetch: ([input, init], next) => { + seen.push({ input, init }); + const method = init?.method ?? "GET"; + return method === "GET" || method === "HEAD" ? next(input, init) : false; + }, + }); + + expect(yield* Permissions.operations.files("read")).toBe(true); + expect(yield* Permissions.operations.files("write")).toBe(false); + expect(yield* Permissions.operations.fetch("https://example.invalid/a")).toBe(true); + expect( + yield* Permissions.operations.fetch("https://example.invalid/b", { method: "POST" }), + ).toBe(false); + }); + + // The arguments the caller passed, not a copy a restriction had to + // reconstruct: what a host narrows destinations by is the request itself. + expect(seen).toEqual([ + { input: "https://example.invalid/a", init: undefined }, + { input: "https://example.invalid/b", init: { method: "POST" } }, + ]); + }); + + it("PM3: a descendant cannot answer true over an enclosing refusal", function* () { + let asked = false; + yield* scoped(function* () { + yield* Permissions.around({ + files: ([operation], next) => (operation === "write" ? false : next(operation)), + }); + + yield* scoped(function* () { + // What a scope that wants more than it was given would write. It never + // runs for the refused class, because the enclosing restriction answers + // without delegating — so this is also the control that the ordering, + // rather than the reading, is what decides. + yield* Permissions.around({ + files: () => { + asked = true; + return true; + }, + }); + + expect(yield* Permissions.operations.files("write")).toBe(false); + expect(asked).toBe(false); + + // The permitted class still reaches it, which is how we know the + // descendant was installed at all rather than silently dropped. + expect(yield* Permissions.operations.files("read")).toBe(true); + expect(asked).toBe(true); + }); + }); + }); + + it("PM4: delegated restrictions intersect, and a sibling keeps its allowance", function* () { + yield* scoped(function* () { + yield* Permissions.around({ + files: ([operation], next) => (operation === "write" ? false : next(operation)), + }); + + yield* scoped(function* () { + yield* Permissions.around({ + files: ([operation], next) => (operation === "read" ? false : next(operation)), + }); + + // Neither class survives both restrictions: each refusal is the one its + // own installer made, and delegation is what carries the other through. + expect(yield* Permissions.operations.files("read")).toBe(false); + expect(yield* Permissions.operations.files("write")).toBe(false); + }); + + // Leaving that scope restores the enclosing allowance. The narrower + // restriction was never a property of the run; it was a property of the + // region, and this is the sibling of it. + expect(yield* Permissions.operations.files("read")).toBe(true); + expect(yield* Permissions.operations.files("write")).toBe(false); + }); + + expect(yield* Permissions.operations.files("write")).toBe(true); + }); + + it("PM5: the root and /api exports are the same Api", function* () { + // Installed through one entrypoint and read through the other. Two modules + // that merely declare the same shape would each answer for themselves + // here; the same Api answers once. + yield* configured.around({ + files: ([operation], next) => (operation === "write" ? false : next(operation)), + }); + + expect(yield* Permissions.operations.files("write")).toBe(false); + expect(yield* configured.operations.files("write")).toBe(false); + expect(yield* Permissions.operations.files("read")).toBe(true); + }); +}); diff --git a/packages/workflow/src/deno/workspace/evaluate.ts b/packages/workflow/src/deno/workspace/evaluate.ts index 19c2d6926..a3443faeb 100644 --- a/packages/workflow/src/deno/workspace/evaluate.ts +++ b/packages/workflow/src/deno/workspace/evaluate.ts @@ -31,8 +31,9 @@ * * `allow` names an effect *class*, and the class resolves to the table stated * here. Omitting it asks for `read`. Asking for `write` reaches core's paired - * ``, workflow's `` and core's self-closing `` and - * nothing else — no Git, no Git host, no Issue, no process, no credential — and + * ``, the directory entry this host captured — `` is canonical + * core's own component now, and what a host supplies is the entry admitting it + * — and core's self-closing ``, and nothing else — no Git, no Git host, no Issue, no process, no credential — and * a host that installed no write table refuses the selection before the * candidate is parsed. * @@ -41,29 +42,37 @@ * effect transaction, and is retained by the `workspace_file` effect that * transaction publishes. Nothing about it reaches the value `` * returns. + * + * ## The operations are the run's own, not this file's + * + * This states names, forms, identities and ceilings, and no operation at all. + * An admitted entry runs canonical core's own component, which performs its + * work through contextual `API.Files` and `API.Fetch` — in a workflow run, + * the transaction-bound provider `workspace/host.ts` installed and the + * transport the workflow host installed. So an admitted write is the same + * durable effect an authored one is, published by the same transaction, + * observed by the same middleware and decided by the same effective + * `Permissions`. A fragment's relative paths resolve against the run's own + * contextual working directory, which is the Workspace root as narrowed by the + * run's own ``, `` and `` — never the directory the + * caller invoked `xmd` from. */ -import { API, cwd, timeoutFetch } from "@executablemd/runtime"; +import { timeoutFetch } from "@executablemd/runtime"; import type { Operation } from "effection"; import { - detachHeaders, - detachStatus, fetchEntry, fileDeleteEntry, fileReadEntry, fileWriteEntry, } from "@executablemd/core/host"; import type { - FetchResponseRecord, FragmentEntry, FragmentEvaluationInput, - FragmentFetchAccess, - FragmentFileAccess, FragmentWorkspaceAccess, GeneratedRequest, } from "@executablemd/core/host"; import type { WorkflowRunDatabase } from "../../storage/api.ts"; -import { workflowFilesHandler } from "./files.ts"; import { workspaceRootSelection } from "./effect.ts"; /** @@ -119,58 +128,6 @@ function workspaceAccess(database: WorkflowRunDatabase): FragmentWorkspaceAccess }; } -/** - * The exact filesystem operations an admitted fragment performs in this run. - * - * This run's own transaction-bound handler, built here and handed to the - * profile, so an admitted effect still crosses the run's effect transaction and - * is still retained by the `workspace_file` effect that transaction publishes. - * What changed is where the handler comes from: a fragment no longer resolves - * `API.Files` when it runs, so the provider a document, a repository component - * or middleware installed nearer is not between a fragment and the Workspace. - * - * Six operations, not seven. The handler also makes temporary directories, and - * an admitted fragment never does. The search is stated because the interface - * lists it, and this profile admits no `` entry for anything to reach it - * through — an operation a fragment may perform is one an entry names, exactly - * as the ordinary run's `ensureDirectory` is stated and admitted to nothing. - */ -function workspaceFiles(database: WorkflowRunDatabase): FragmentFileAccess { - const handler = workflowFilesHandler(database); - return { - checkFilePath: (input) => handler.checkFilePath(input), - readTextFile: (input) => handler.readTextFile(input), - globFiles: (input) => - handler.globFiles({ - cwd: input.cwd, - include: [...input.include], - exclude: [...input.exclude], - }), - writeTextFile: (input) => handler.writeTextFile(input), - deleteFile: (input) => handler.deleteFile(input), - ensureDirectory: (input) => handler.ensureDirectory(input), - // The directory the run is in now, which is what every authored path in - // this run resolves against. Read when a fragment runs rather than frozen - // at assembly, so a fragment and the document that produced it resolve the - // same relative path to the same entry — an `` written inside - // `` proposes into that checkout, and the authored - // `` beside it stages what the fragment wrote. - // - // Still a logical path rather than a host one: this run's `API.Env.cwd` is - // installed at `at: "min"` as the Workspace root and narrowed only by the - // run's own ``, `` and ``, so every answer names - // a directory inside the run's own filesystem and nothing an admitted - // fragment writes reaches the directory the caller invoked `xmd` from. - // - // It is not permission. The admitted tables, the path admission each - // operation performs and the run's effect transaction are the same at every - // directory, and the working directory is no member of the `generated_xmd` - // record a continuation compares — so narrowing from the root to the - // checkout the element was written in grants nothing it did not have. - workingDirectory: () => cwd(), - }; -} - /** * The ceiling a workflow run's generated fragments are admitted under. * @@ -184,11 +141,6 @@ export function* evaluationProfile( options: GeneratedEvaluationOptions = {}, ): Operation { const requests = [...(options.requests ?? [])]; - // The transport, read once here rather than by a fragment when it runs. It is - // the run's own `API.Fetch` as this assembly sees it — which is the provider - // the workflow host installed, not whichever one a document later composes - // around itself. - const transport = yield* fetchAccess(); const timeout = yield* timeoutFetch; return { read: [ @@ -198,10 +150,11 @@ export function* evaluationProfile( ], write: [ fileWriteEntry(), - // The directory capability is the host's to supply, and a host that - // supplies none grants none: `` belongs to `@executablemd/git`, and - // a generic Workflow host composing this profile without it is not - // withholding a capability so much as never having had one. + // The directory entry is the host's to supply, and a host that supplies + // none grants none. Canonical core owns `` itself, so what a host + // states here is the admission rather than the component: a generic + // Workflow host composing this profile without it is not withholding an + // implementation so much as never having admitted the name. // // Its position is the contract. A released journal holds this table // position by position, so the entry a host does supply occupies the @@ -211,42 +164,7 @@ export function* evaluationProfile( fileDeleteEntry(), ...(options.writes ?? []), ], - files: workspaceFiles(database), - ...(requests.length === 0 ? {} : { fetch: transport }), workspace: workspaceAccess(database), ...(timeout === undefined ? {} : { fetchTimeout: timeout }), }; } - -/** - * The transport an admitted `` performs its request through. - * - * Resolved at assembly, so the operation the profile holds is the one this host - * installed. A fragment reaches this bound function and never `API.Fetch`, so a - * handler composed around the contextual chain while the document runs neither - * sees the request nor answers it. - */ -// deno-lint-ignore require-yield -function* fetchAccess(): Operation { - // Read here, at assembly, and closed over. `API.Fetch.operations` resolves - // against whatever chain is current when it is *called*, so reading it inside - // the fragment's own body would be the dynamic lookup this exists to remove. - const perform = API.Fetch.operations.fetch; - return { - *fetch(request): Operation { - const response = yield* perform(request.url, { - method: request.method, - headers: { ...request.headers }, - ...(request.timeout === undefined ? {} : { timeout: request.timeout }), - }); - // Detached before the body is read, because a provider may invalidate its - // own header collection once the body has been consumed. A `HEAD` never - // asks for a body: there is none, and asking would fail against a - // provider that says so. - const status = detachStatus(response.status); - const headers = detachHeaders(response.headers); - const body = request.method === "HEAD" ? "" : yield* response.text(); - return { status, headers, body }; - }, - }; -} diff --git a/packages/workflow/src/deno/workspace/files.ts b/packages/workflow/src/deno/workspace/files.ts index b01a17e1d..777bf534d 100644 --- a/packages/workflow/src/deno/workspace/files.ts +++ b/packages/workflow/src/deno/workspace/files.ts @@ -23,6 +23,22 @@ * entry, so a check that was skipped or answered elsewhere authorizes nothing; * the write repeats the same admission from the same authored path. * + * ## Where permission is asked + * + * Inside the live performer, as its first step. `Permissions` is a question + * about work that is about to happen, and under replay no work is about to + * happen: a check placed around `performed()` would re-decide an operation that + * has already been published, so a run continued under a narrower policy would + * contradict its own journal — and a run continued under a wider one would ask + * for permission it no longer needs. Asking inside the performer means a + * completed effect restores what it recorded and only new work meets current + * policy. + * + * A refusal is an ordinary live outcome, published like any other and + * sanitized the same way, so the journal reads "this operation was refused" + * rather than "this run failed". It changes no target and captures the root it + * already had. + * * ## What replay does instead * * A recorded effect restores its recorded value. A read therefore answers with @@ -56,6 +72,7 @@ import { FilesInvariantError, FilesOperationDeniedError, filesFailure, + Permissions, fileWriteFailure, fileWriteSuccess, parseFilesPhase, @@ -359,6 +376,9 @@ function* readOutcome( filesystem: DenoWorkspaceFilesystem, path: string, ): Operation> { + if (!(yield* Permissions.operations.files("read"))) { + return refused("resolution", "permission-denied"); + } const info = yield* statPath(filesystem, path); if (!info.ok) { return refused("resolution", refusalReason(info.error)); @@ -421,6 +441,9 @@ function* ensureOutcome( filesystem: DenoWorkspaceFilesystem, path: string, ): Operation> { + if (!(yield* Permissions.operations.files("write"))) { + return refused("resolution", "permission-denied"); + } if (path === WORKSPACE_ROOT) { return { kind: "ensured" }; } @@ -460,6 +483,9 @@ function* writeOutcome( path: string, content: string, ): Operation> { + if (!(yield* Permissions.operations.files("write"))) { + return refused("resolution", "permission-denied"); + } const existing = yield* classifyWriteTarget(filesystem, path); if (existing !== undefined) { return existing; @@ -499,6 +525,9 @@ function* deleteOutcome( filesystem: DenoWorkspaceFilesystem, path: string, ): Operation> { + if (!(yield* Permissions.operations.files("write"))) { + return refused("resolution", "permission-denied"); + } const info = yield* lstatPath(filesystem, path); if (!info.ok) { const reason = refusalReason(info.error); @@ -623,6 +652,9 @@ function* globOutcome( include: string[], exclude: string[], ): Operation> { + if (!(yield* Permissions.operations.files("read"))) { + return refused("target", "permission-denied"); + } const info = yield* statPath(filesystem, directory); if (!info.ok) { return refused("target", refusalReason(info.error)); diff --git a/packages/workflow/tests/workspace-files.test.ts b/packages/workflow/tests/workspace-files.test.ts index 2200b2c7c..6f9fb6776 100644 --- a/packages/workflow/tests/workspace-files.test.ts +++ b/packages/workflow/tests/workspace-files.test.ts @@ -30,7 +30,13 @@ import { collect, execute, inlineSource, registerComponents } from "@executablem import type { Json } from "@executablemd/durable-streams"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; -import { API, FILES_FATAL, parseFilesFatal, useHostFiles } from "@executablemd/runtime"; +import { + API, + FILES_FATAL, + parseFilesFatal, + Permissions, + useHostFiles, +} from "@executablemd/runtime"; import type { HostFilesEvent } from "@executablemd/runtime"; import type { WorkflowRunDatabase } from "../mod.ts"; import { withWorkflowWorkspace } from "../src/deno/workspace/host.ts"; @@ -267,6 +273,19 @@ function countingRemoves( }); } +/** Every file this run actually wrote, in order. */ +function countingWrites( + written: string[], +): (filesystem: DenoWorkspaceFilesystem) => DenoWorkspaceFilesystem { + return (filesystem) => ({ + ...filesystem, + *writeFile(path, content, mode) { + written.push(path); + yield* filesystem.writeFile(path, content, mode); + }, + }); +} + /** Every directory this run actually created, in order. */ function countingMkdirs( made: string[], @@ -1734,3 +1753,168 @@ describe("Tier WF — the run's own directories", () => { ); }); }); + +/** + * Permission, asked by the live performer. + * + * The run is what makes these worth having separately from the host provider's + * rows: a workflow operation is a durable effect, so "was it permitted" and + * "did it already happen" are two different questions, and the second one has + * to win. What these assert is that current policy decides new work and says + * nothing about work the journal already holds. + */ +describe("workflow document filesystem — permission", () => { + /** Refuse one class of operation, recording every question asked. */ + function restricted(asked: string[], refuse: "read" | "write"): Operation { + return Permissions.around({ + files: ([operation], next) => { + asked.push(operation); + return operation === refuse ? false : next(operation); + }, + }); + } + + it("WF27: a denied write is published as an ordinary refusal and keeps the root", function* () { + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + const path = runPath(root, database.record.runId); + yield* mutateWorkspace(database, function* (workspace) { + yield* workspace.filesystem.writeFile("/kept.txt", "kept"); + }); + const before = committedRoot(path); + const asked: string[] = []; + + const run = yield* scoped(function* () { + yield* restricted(asked, "write"); + return yield* runDocument( + database, + ['', "", 'no'].join("\n"), + ); + }); + + // The read was permitted and the write was not, so the class each + // operation asked for is what decided it. + expect(asked).toEqual(["read", "write"]); + expect(run.host.seen).toEqual([]); + + const recorded = yield* recordedFileEffects(database); + expect(recorded.map((effect) => effect.name.split(":")[0])).toEqual(["read", "write"]); + expect(recorded[0]?.result).toEqual({ + status: "ok", + value: { kind: "content", content: "kept" }, + }); + // An ordinary refusal, in the phase the performer stopped at, published + // rather than raised: the run continues and the journal says what + // happened. + expect(recorded[1]?.result).toEqual({ + status: "ok", + value: { kind: "refused", phase: "resolution", reason: "permission-denied" }, + }); + + // Nothing was mutated, so the root the refusal published is the one it + // started from and the Workspace is what it was. + expect(committedRoot(path)).toEqual(before); + const created = yield* transactWorkspaceRoots(database, function* (workspace) { + return yield* workspace.filesystem.stat("/new.txt"); + }); + expect(created.ok).toEqual(false); + expect(yield* workspaceText(database, "/kept.txt")).toEqual("kept"); + }); + }); + + it("WF28: a completed effect replays although writes are now refused", function* () { + const root = yield* useStorageRoot(); + const written: string[] = []; + yield* withStorage( + root, + function* () { + const database = yield* createRun(); + const source = 'first'; + + yield* runDocument(database, source); + expect(written).toEqual(["/recorded.txt"]); + const recorded = yield* recordedFileEffects(database); + const before = yield* database.journal.readAll(); + + const asked: string[] = []; + const replayed = yield* scoped(function* () { + yield* restricted(asked, "write"); + return yield* replayDocument(database, source); + }); + + // The question was never asked, because there was no work to permit: + // replay restores what the journal holds without entering the + // performer that would have asked. + expect(asked).toEqual([]); + expect(written).toEqual(["/recorded.txt"]); + expect(replayed.host.seen).toEqual([]); + expect(yield* recordedFileEffects(database)).toEqual(recorded); + expect((yield* database.journal.readAll()).length).toEqual(before.length); + expect(yield* workspaceText(database, "/recorded.txt")).toEqual("first"); + }, + { decorateFilesystem: countingWrites(written) }, + ); + }); + + it("WF29: work the journal does not hold asks current policy", function* () { + const root = yield* useStorageRoot(); + const source = ['A', "", 'B'].join("\n"); + + // A run interrupted between its two writes. The journal has to be + // *incomplete* for this row to mean anything: a complete one replays + // without entering a component at all, so appending work to a finished run + // would prove nothing about policy. + const reached = withResolvers(); + yield* withStorage( + root, + function* () { + const database = yield* createRun(); + // Halted where the signal says, not after a deadline: the second write + // has landed and has not been published when the halt arrives. + const running = yield* spawn(() => raised(runDocument(database, source))); + yield* reached.operation; + yield* running.halt(); + + const recorded = yield* recordedFileEffects(database); + expect(recorded).toHaveLength(1); + expect(recorded[0]?.result).toEqual({ status: "ok", value: { kind: "written" } }); + expect(yield* workspaceText(database, "/a.txt")).toEqual("A"); + }, + { decorateFilesystem: suspendingWrite("/b.txt", reached) }, + ); + + const written: string[] = []; + const asked: string[] = []; + yield* withStorage( + root, + function* () { + const database = yield* createRun(); + yield* scoped(function* () { + yield* restricted(asked, "write"); + return yield* raised(runDocument(database, source)); + }); + + // One question, asked for the one write this run still had to do. The + // recorded write ahead of it neither asked nor reached the filesystem. + expect(asked).toEqual(["write"]); + expect(written).toEqual([]); + + const recorded = yield* recordedFileEffects(database); + expect(recorded).toHaveLength(2); + expect(recorded[0]?.result).toEqual({ status: "ok", value: { kind: "written" } }); + expect(recorded[1]?.result).toEqual({ + status: "ok", + value: { kind: "refused", phase: "resolution", reason: "permission-denied" }, + }); + + const created = yield* transactWorkspaceRoots(database, function* (workspace) { + return yield* workspace.filesystem.stat("/b.txt"); + }); + expect(created.ok).toEqual(false); + expect(yield* workspaceText(database, "/a.txt")).toEqual("A"); + }, + { decorateFilesystem: countingWrites(written) }, + ); + }); +}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 953ecef2c..62fbaa986 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -183,6 +183,48 @@ directory persists and content failure or cancellation does not remove it; the component's scope restores only the enclosing cwd. Repository context is not installed or replaced by Dir. +#### Who may perform a document operation + +One boundary, and one policy on it. An authored element, an element inside an +admitted generated fragment (§5.3.2) and a component calling the contextual +operation itself all perform the *same* operation through the same `API.Files` +provider, so one middleware observes, transforms, delegates or refuses all +three, and all three resolve their relative paths against the same contextual +`Env.cwd`. There is no second filesystem for generated work and no working +directory of the evaluator's own. + +Whether the scope asking may perform the operation is `Permissions`, a runtime +Api with two members — `files("read" | "write")` and `fetch(input, init?)` — +both answering `true` until something says otherwise. A scope that permits less +installs middleware that returns `false` for what it refuses and delegates +everything else unchanged; delegation is what makes restrictions intersect, so a +scope nested inside a read-only region cannot permit a write by answering +`true`, and a sibling outside that region keeps its enclosing allowance. + +The question is asked by the implementation that performs the operation, +immediately before its first I/O — the provider for a filesystem operation, the +network terminal for a request — and nowhere else. Two consequences are part of +the contract. A request is authorized *after* the contextual middleware that may +transform it, so what is permitted is what would actually be performed. And a +denied operation performs no I/O of any kind on its target: nothing resolved, +nothing classified, nothing traversed, nothing created, and a write reports an +unchanged target. The refusal is an ordinary filesystem failure carrying +`permission-denied`, never an infrastructure error. + +Which class a request belongs to is decided by what the caller asked for rather +than by what the filesystem turns out to hold: ensuring a directory that already +exists and deleting a path that names nothing are both `write`, because deciding +otherwise would mean probing the filesystem to find out which question to ask. +`checkFilePath` asks nothing, because it reaches nothing. Acquired resources are +cleaned up by the operation that acquired them without a fresh request, and a +completed durable effect restores its recorded outcome without asking current +policy at all. + +`Permissions` mediates those semantic operations and nothing by implication. The +low-level `API.Fs`, subprocesses, Git host operations and every other native +reach are not covered by it, and a `true` answer supplies no capability — the +operation still needs a provider that can perform it. + #### The contextual working directory `exec` and `daemon` blocks launch their processes in the **contextual working @@ -1931,6 +1973,7 @@ run but are absent from the diagnostic trace. | `src/modifiers/timeout.ts` | `timeoutFactory` | | `packages/runtime/duration.ts` | `asDuration()`, `parseDuration()`, `durationError()` — the one duration grammar | | `packages/runtime/config.ts` | `Config`, the validated `timeout` / `timeoutExec` / `timeoutFetch` operations, and the validated `verbose` operation | +| `packages/runtime/permissions.ts` | `Permissions` — whether the scope asking may perform the semantic operation `API.Files` or `API.Fetch` is about to carry out (§1.3) | | `src/modifiers/daemon.ts` | `daemonFactory` — long-running subprocess terminal modifier | | `src/modifiers/ephemeral.ts` | `ephemeralFactory` — replay-safe live eval wrapper | | `src/modifiers/service.ts` | `serviceFactory` — scoped service attachment | @@ -4242,22 +4285,31 @@ effect tables refuse at profile capture. Generated Syntax describes the same union preflight and execution use, and continuation reconciles every admitted composition identity. -**Entries name capabilities, not definitions.** A host states which operation an -admitted name runs; canonical capture reads that operation off the host's object -exactly once, binds it behind a revocation the execution owns, and closes core's -own body over the bound result. An admitted element therefore reaches those -operations and never `API.Files`, `API.Fetch` or `API.Env` — so what a fragment -may do is not a property of what the document, a repository component or -middleware installed. A directory an admitted fragment creates scopes its -content through the evaluation's own cursor rather than the contextual -environment. - -A capability states what it binds when the ordinary component does. The -admitted definition carries that return contract, so `as` is mandatory, the -element renders nothing, and the bound value is validated — decided by the -ordinary value-component rule, before the operation behind the entry runs. A -name admitted for two spellings that would bind different results is refused at -capture. +**Entries name components, not operations.** A host states which component an +admitted name runs, and canonical capture answers with canonical core's own +ordinary definition for it — the same ``, ``, ``, +``, `` or `` an author writes. The host supplies no +operation of its own: an admitted element performs its work through contextual +`API.Files` and `API.Fetch` exactly as an authored element does, so a generated +read crosses the middleware an authored read crosses, reaches the provider the +host installed — the caller's own filesystem under `xmd run`, a workflow run's +transaction-bound one under `xmd workflow` — and is decided by the effective +`Permissions` of the scope it runs in. A directory an admitted fragment creates +scopes its content through the ordinary invocation-owned working directory, and +the invocation that installed it takes it away again. + +What a host decides is admission and nothing about performance: which names a +fragment may write, which spelling each name is admitted for, which requests an +admitted `` may perform, and how long any of it lasts. An admitted +implementation is held to the execution that captured it and refuses once that +execution has ended. + +A capability states what it binds when the ordinary component does, because it +*is* the ordinary component: the definition carries that return contract, so +`as` is mandatory, the element renders nothing, and the bound value is validated +— decided by the ordinary value-component rule, before the operation behind the +entry runs. One name runs one component, so a name admitted for capabilities +owned by two different components is refused at capture. **The ordinary read table is File, Glob and canonical Syntax.** `xmd run` and every `host="run"` child state one profile: `read` holds the self-closing @@ -4299,10 +4351,10 @@ An admitted `` is the same element an author writes. Its props, return contract, source rules and sanitized failure sentence are one shared definition with the ordinary component, so a pattern that cannot match anything under a relative root is refused before the search runs, and a provider failure is -normalized to a sentence naming no path. It performs that search through the -captured search operation and the captured working directory, never `API.Files` -and never `Env.cwd`, and the provider still owns matching, ordering, -deduplication, POSIX-relative results and traversal. +normalized to a sentence naming no path. It performs that search through contextual +`API.Files` against the contextual working directory, exactly as the authored +element does, and the provider still owns matching, ordering, deduplication, +POSIX-relative results and traversal. `` is admitted as a component answer at canonical core's own origin, key and revision, never as a capability: a protected name is one canonical core @@ -7876,6 +7928,12 @@ pwd The block prints a fresh directory, and by the time the document finishes that directory is gone. +Acquiring one is a `write`, asked of `Permissions` by the provider before the +directory is created (§1.2): a refused acquisition creates nothing at all. +Releasing one the scope already holds asks nothing — the cleanup belongs to the +operation that acquired it, so a scope that narrowed its own permission +afterwards still has its temporary directory removed. + #### The two forms Written **with content**, `` is a working directory. It creates a @@ -8252,6 +8310,14 @@ member or import-handler answer supplies it. It takes one required prop, ``` +Both forms perform their work through the installed `API.Files` provider, which +asks `Permissions` whether the scope reading or writing may do so before its +first I/O (§1.2): a refused read or write is an ordinary `permission-denied` +failure that resolved nothing, classified nothing and left its target exactly as +it was. Which class is asked for is the form — reading asks `read`, writing asks +`write` — and the pure path check a write performs before its children asks +nothing at all. + Because the path is contextual, `` composes with `` (§6.11) without either component knowing about the other: @@ -8884,6 +8950,10 @@ contextual `Env.cwd` a set of patterns selects: ``` +The search is a `read`, asked of `Permissions` by the provider before it touches +the directory it would walk (§1.2); a refusal traverses nothing and reports the +ordinary sanitized sentence. + `include` is required and holds at least one pattern. `exclude` is optional and defaults to empty. Both are lists of glob patterns, and both are evaluated relative to `Env.cwd`, so `` composes with `` (§6.11) and with @@ -9452,6 +9522,13 @@ observation as one durable effect. It is how an Agent that has no network of its own is given external information: the document performs the read and the result reaches the prompt as data. +The network terminal asks `Permissions.fetch` with the request as it would go +out — after every `API.Fetch` middleware has had its turn, so a restriction +reads the destination, method and headers that would actually be sent rather +than the caller's first draft (§1.2). A refusal throws a fixed sanitized +sentence before transport starts: no request is made, and there is no response +to bind. + #### The props | Prop | Required | Contract | @@ -11772,6 +11849,46 @@ platform's. | HF26 | Non-directory refusal | A regular file and a supported special entry, at the target and as an intermediate entry, each refuse before content with a sanitized reason and no host path, platform code, raw message or cause | | HF27 | Direct persistence | A directory created by `` remains after successful content, failed content and cancellation; cwd restoration performs no teardown deletion | | HF28 | Repository independence | Ensuring and entering a directory beneath a selected checkout leaves every Repository identity member and provider selection unchanged | +| HF29 | Each operation asks its own class | Read and search ask `read`; write, delete, directory ensure and temporary acquisition ask `write`; `checkFilePath` asks nothing and still answers. A refusal returns valid sanitized failure data with `permission-denied`, reports an unchanged write target, and performs no `API.Fs` call at all — nothing resolved, classified or traversed. Asserted against the handler directly, so the obligation is the provider's rather than the installing middleware's | +| HF30 | A denied acquisition creates nothing | The configured temporary root holds no minted directory afterwards, which is the one refusal an `API.Fs` census cannot prove | +| HF31 | Cleanup needs no fresh permission | A temporary acquired before a write restriction is installed is still released when its scope ends, and its root is empty afterwards | + +### Tier PM — The Permissions Api + +Composition, and nothing else: no filesystem, no transport and no provider +appears, because the rows proving a provider asks the question live beside the +provider. Every restriction installs at the default order, which is the +convention a host writing one follows and the ordering the guarantee depends on. + +| # | Test | Verify | +|---|------|--------| +| PM1 | Defaults | Both members permit when nothing is installed | +| PM2 | One class, and the actual request | A restriction refuses its own class and leaves the other alone; a Fetch restriction reads the exact input and init the caller passed | +| PM3 | Ordering | A descendant answering `true` never runs for a class the enclosing restriction refuses, and does run for one it permits — so the ordering rather than the reading is what decides | +| PM4 | Intersection and the sibling | Two delegated restrictions refuse both classes; leaving the narrower scope restores the enclosing allowance, and leaving both restores the default | +| PM5 | One Api | A restriction installed through `/api` is read through the root export and answers once | + +### Tier PRM — Authored, generated and direct operations under one restriction + +What only a document can show: an authored element, an element inside an +admitted generated fragment and a direct contextual call are the same +operation, so one restriction decides all three. Every claim is checked in what +the document reported *and* in what the filesystem holds afterwards. The region +is a component, because a region has an inside and an outside and the sibling +written after it is what makes the outside observable. + +| # | Test | Verify | +|---|------|--------| +| PRM1 | Every class of read | Authored, generated and direct reads are all answered inside a read-only region, with no printed error | +| PRM1b | The authored write | Refused with `permission-denied`, the target absent afterwards, and the sibling written outside the region committed — the restriction belonged to the region rather than to the run | +| PRM1c | The direct write | A component calling the contextual operation itself is refused on the same terms, and its sibling outside the region commits | +| PRM1d | The generated write | The write form is admitted so preflight lets the fragment try; the live restriction refuses it at the provider, the element fails as public `` does, and nothing is written | +| PRM2 | A widening descendant | A nested region answering `true` at the default order changes nothing: the provider still reads a refusal | +| PRM3 | Authored order under refusal | The pure path check runs, the children expand — the read inside really happens — and authorization stands at the write, which is refused with the target absent | +| PRM4 | One chain, both payloads | A document's own middleware observes the fragment's read and write in the fragment's order, and the content it substituted is what the provider committed | +| PRM5 | A generated directory | `` inside a fragment creates the directory, scopes the file written inside it, and restores the enclosing directory for the file written after it | +| PRM6 | The admitted request | A generated `` performs its entry's admitted request and a real loopback accepts exactly it | +| PRM7 | The ceiling at the transport | Middleware rewriting the destination off the entry's ceiling is refused by the terminal with the fixed sentence, and the transport accepts nothing | ### Tier FF — Files infrastructure failure @@ -12136,13 +12253,13 @@ through the captured capability because there is no other way to reach it. | FE26 | Protection itself grants nothing: a profile with no write table stays unable to write, and adding `` to the protected table does not add a class or component identity to `allow`. | | FE27 | Structural constructs remain selected by structural dispatch, while repository replacements for ordinary ``, ``, and `` still win at ordinary authored sites. Their replaceability does not let them enter a generated fragment unless the trusted profile admitted that exact identity and form. | | FE28 | Source, npm, and compiled symbols report `` with protected origin and the approved description, and no host bootstrap is needed to make the name available. | -| FE29 | Each execution accepts one private fragment-evaluation profile containing exact composition, read and write tables; a missing or duplicate profile and conflicts across its tables refuse before paired-content production or fragment effects, and document-controlled state cannot read, replace, or widen it. | +| FE29 | Each execution accepts one private fragment-evaluation profile containing exact composition, read and write tables; a missing or duplicate profile and conflicts across its tables refuse before paired-content production or fragment effects, and document-controlled state cannot read or widen it — it cannot add a name to the admitted vocabulary, change the identity a continuation is compared against, or give a selection a class the host installed no table for. What a document *can* do is answer the operation, because the provider is deliberately shared: a handler installed anywhere in the document's own scope is what an admitted element reaches, exactly as an authored one does. | | FE30 | A trusted nested-run or evaluation-host layering control enters the same declarative package bootstrap through inherited and local layers. The child keeps the package registration and renders the same named documentation as the single-bootstrap control; Evaluate still admits only the identity selected by `allow`. A non-identical owner/component overlap refuses before child root or fragment effects. This receives #765's SYN25l.5 rather than creating a second collector in Evaluate. | | FE31 | One fragment binds File and protected Syntax results locally and explicitly renders a chosen object through Json. Nested literal arrays and objects, binding shorthand, finite JSON numbers and a directly attached leading minus such as `` work in scalar, array and object positions. `1e999`, `-1e999`, `+1`, `-note`, `!note`, `typeof note`, an unbound identifier, call, operator, spread, computed property, template or global reference is refused before any earlier effect. | | FE32 | Generated admission and effect events follow ordinary persist-before-resume publication. A later refusal, failure, cancellation or interruption retains completed effects, and continuation resumes at the occurrence's first unrecorded effect; Evaluate adds no staging or rollback. | | FE33 | Each Evaluate occurrence retains its admission and generated effects under identities belonging to that occurrence; two occurrences cannot consume one another's retained work, and no caller-controlled context, stream, replay cursor, coroutine identifier or durable owner participates. A projected body installs observable structured cleanup, and later parent work proves that cleanup completed before it began. No distinct child cursor, child `Close`, staging, rollback, provisional publication or second settlement protocol is required. | | FE35 | A fragment composes admitted reads with ``, ``, ``/`` and ``/`` under ordinary rules, performing exactly the reads the taken paths name. A prohibited component in an untaken `` arm or an unreached `` refuses the whole fragment with zero reads, and the refusal names the selected permission rather than the branch. An `` item binding does not escape its body while `` does, and a binding the body itself makes cannot be read after the construct. Neither arm of an `` nor either `` of a `` can read a binding another alternative produces; each such refusal performs no read. A `` holding a valid `` and a `` region around an admitted read completes, proving those constructs reach their own handlers rather than a generic structural refusal. A prohibited component inside a paired ``'s template children, and one in the ordinary `` body, are each refused before any effect. An untaken self-closing `` before a selected paired `` write does not surrender its admission to that write: the write performs, the read does not, and the untaken file is unchanged. A two-item `` around one `` performs the read twice with no import refusal, an empty iteration consumes no admission, and a continuation cut between the two repeated effects restores the completed one and resumes at the second. An executable fence and an interpolated caller binding stay refused inside a construct. An ill-formed construct, one the generated root gives no context for, a stray branch and a `` outside every `` are refused as structural mistakes rather than as withheld components. | -| FE34 | One fragment binds Glob, File and canonical Syntax and renders chosen findings through Json; an unmatched search binds `[]`. A search written without `as`, a paired search, and a paired `` under a `read` selection each refuse before any operation. An unusable pattern refuses with the ordinary sentence before the search, and a provider failure becomes one safe sentence naming no path. The search reaches the captured operation and the captured working directory while a nearer `API.Files` provider is never consulted. Bare generated Syntax reports the composition table plus the selected read vocabulary and not the unselected write table; named Syntax describes a component that stays unavailable. Canonical Syntax resolves at core's own identity, and a middleware replacement of that answer refuses. Core answers for that one protected name, so an entry hand-written at ``'s canonical identity is refused as an answer nothing identified. `xmd run` and a `host="run"` child of `xmd test` state the same profile. | +| FE34 | One fragment binds Glob, File and canonical Syntax and renders chosen findings through Json; an unmatched search binds `[]`. A search written without `as`, a paired search, and a paired `` under a `read` selection each refuse before any operation. An unusable pattern refuses with the ordinary sentence before the search, and a provider failure becomes one safe sentence naming no path. The search reaches the installed `API.Files` provider and the contextual working directory, so one middleware around the document sees the fragment's request and a transformation it makes is what the provider performs. Bare generated Syntax reports the composition table plus the selected read vocabulary and not the unselected write table; named Syntax describes a component that stays unavailable. Canonical Syntax resolves at core's own identity, and a middleware replacement of that answer refuses. Core answers for that one protected name, so an entry hand-written at ``'s canonical identity is refused as an answer nothing identified. `xmd run` and a `host="run"` child of `xmd test` state the same profile. | Each refusal case needs a negative control proving no producer, middleware answer, request, file mutation, or other program effect occurred. The @@ -12657,6 +12774,9 @@ Defined in [Workflow runs](./workflow-spec.md) §10. | WF24 | Directory refusal rollback | A target or intermediate non-directory rolls its savepoint back, publishes only the sanitized refusal against the unchanged root and begins no content; neither a partial parent nor host detail survives | | WF25 | Existing directory and retained root | Ensuring an existing populated directory preserves its contents and commits a successful effect whose resulting root is the run's authoritative current root | | WF26 | Directory replay and cancellation | Completed replay restores the recorded success and retained root without creating again; cancellation before commit publishes no effect or root, while cancellation of later content leaves the committed directory and restores the enclosing cwd | +| WF27 | A denied write | The live performer publishes the ordinary sanitized `permission-denied` refusal in the phase it stopped at, the current root and the Workspace are the ones it started with, and a read in the same run is still permitted — the class each operation asked for is what decided it | +| WF28 | A completed effect under a narrower policy | Replay restores the recorded outcome, asks no permission at all and enters no performer, and appends no journal event | +| WF29 | Unrecorded work | After an interrupted run, the continuation's one unrecorded write asks current policy and is refused, while the recorded write ahead of it neither asks nor reaches the filesystem | ### Tier EP — The execution protocol @@ -13482,6 +13602,8 @@ Runs against a loopback server, so what is asserted is what a real host reports. | FR9–FR10 | The chainable shape | `text()`, `json()`, and `expect()` — the calling shape authored eval code uses | | FR11 | One transport | Every form of the call crosses `API.Fetch`, so a host refusal covers all of them | | FR12 | The contextual default | A call that names no timeout resolves `Config.timeoutFetch` | +| FR13 | The request a restriction answers for | A host handler routes and marks every request; the restriction reads the routed URL and the added header rather than the caller's spelling, the permitted one reaches the loopback, and a refused one — through the direct Api and the public chain alike — throws the fixed sanitized sentence with no request sent and no response bound | +| FR14 | A synthetic answer | Middleware that answers without delegating succeeds under a restriction refusing everything, asks no permission at all, and reaches no transport | ### Tier FT — `` (§6.18) diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 759b14316..ea794220c 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -700,6 +700,31 @@ construct. `` selects only `read`. It installs no profile of its own, creates no second evaluator and starts no child execution; a trusted host that already stated a profile keeps it. +*The request travels the ordinary route, and the writer frame is the reason that +takes saying.* The frame a Plan is written under refuses every capability it can +reach, and an admitted read is now the ordinary `` or `` performing +its work through contextual `API.Files` — so it arrives at that same refusal. +The one exception is this phase of the Component, recognized by **where** the +call was made from rather than by what it asked for: the authenticated +information phase holds a private read grant for the scope its projection runs +in, and the frame's guard delegates `checkFilePath`, `readTextFile` and +`globFiles` for that scope alone, through its own `next`. + +Delegation is all it does. Every enclosing and descendant Files middleware keeps +its ordinary turn, the installed provider still performs the operation, and the +effective `Permissions` of the consuming scope still decide it — so a read +restriction in force around the document denies the request, and the denial +becomes one safe retry context like any other ordinary refusal. The grant +answers Permissions with nothing and runs the request in no wider scope. + +Reads only, and that phase only. Every mutation stays refused wherever it is +asked from — write, delete, directory ensure and temporary acquisition — and so +does every read outside a live projection: before one opens, after it settles, +in a concurrent sibling of the frame that never held a grant, and in a nested +writer frame whose own grant belongs to its own conversation. Process, Fetch and +Service refusal and the native Agent policy are unchanged across the whole +frame. + *The budget is eight requests for one invocation*, shared across initial drafting, repair and revision. A success and a safe refusal each spend one. They neither consume nor reset the ten-draft and three-repair budgets, and those do @@ -1118,4 +1143,8 @@ neither observation never interpreted what it wrote. | PI9 | Distribution | The embedded request-to-approved-Plan journey executes through source, emitted npm and compiled installations. The command journey executes through the in-process production command assembly and exact packaged command document. Npm and compiled controls verify packaged command and Plan assets, Plan identity and digest, its exact text contract and private closure, and the canonical protected Syntax/Evaluate tier | | PI10 | Recoverable versus terminal | A malformed request and an ordinary read `Err` are each classified by core as a refused generated request, which `` alone turns into one safe retry context; missing or broken profile or protected route, a throwing Files provider, stale or corrupt history, journal or secret failure, unexpected runtime failure, teardown failure and outer cancellation each stop authorship. Public `` still throws under ordinary use | | PI11 | Language, not permission | Branching, binding and bounded iteration compose with admitted reads under ordinary rules, and a prohibited component in an untaken branch refuses the whole fragment with zero reads | +| PI20 | The shared route, from both sides | One middleware the document installed observes the request the fragment made, a second installed nearer the provider transforms it, and the findings the next turn receives carry the transformed answer — so an information request is the ordinary operation and not a private one | +| PI21 | Current policy decides it | A read restriction the enclosing scope installed denies the request before the provider performs anything; the refusal becomes exactly one more turn carrying the sanitized sentence, and authorship still finishes. The ordering half — a descendant answering `true` cannot defeat an enclosing `false` — is Tier PM's | +| PI22 | The writer frame still refuses everything else | Inside the frame and outside any projection, a read, a temporary directory, a semantic directory ensure, a command and the network are each refused with the frame's own fixed sentence | +| PI23 | The shape of the grant | A frame grants nothing until a projection opens; a projection grants its own scope and every scope created under it; a sibling created while one is open is granted nothing; one frame's projection does not satisfy another frame's guard; and a retained guard answers no once its frame has ended | | PI12 | Disclosure order | Observe default, verbose, journal and Agent prompts for success and refusal, then place a synthetic secret in a file read by an information request. Default output stays content-free; detailed findings follow settlement; the secret appears in no output, journal entry or Agent Prompt, starts no following turn, review or artifact, and ends with the existing terminal secret rejection | diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index ba6a5b8e7..985bce81e 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -2060,13 +2060,24 @@ anything else is installed. An execution accepts one profile and refuses two, and a run whose host states none has no evaluation at all — which `` says when it is written rather than at startup. -Each entry in that profile names a **capability** rather than a definition. -Canonical capture reads the host's own operation off its object exactly once, -binds it behind a revocation the execution owns, and closes core's own body over -the bound result. An admitted `` therefore reaches the `readTextFile` -this attachment handed over — still the run's transaction-bound one — and never -resolves `API.Files` while it runs. That is what keeps a fragment's permission -out of reach of the document, a repository component and middleware. +Each entry in that profile names a **component** rather than an operation. +Canonical capture answers with core's own ordinary definition for it, held to +the execution's lifetime, and this attachment hands the profile no operation at +all. An admitted `` therefore performs the ordinary `` through +contextual `API.Files` — which in a workflow run is the transaction-bound +provider `withWorkflowWorkspace` installed — so an admitted write is the same +durable effect an authored one is, published by the same effect transaction, +observed by the same middleware and decided by the same effective +`Permissions`. Its relative paths resolve against the run's own contextual +working directory, which is the Workspace root as narrowed by the run's own +``, `` and ``, and never the directory the caller +invoked `xmd` from. + +What keeps a fragment's permission bounded is therefore not which object +answered. It is the entry tables — which names, in which spelling, under which +identity a continuation is compared against — the ceiling an admitted `` +carries, the policy in force where the element runs, and the run's own logical +filesystem, which contains no host path to name. **How its durable operation is named.** The implementation claims on the exact invocation the engine handed it, in that invocation's own frame. A durable name @@ -2371,6 +2382,17 @@ question rather than a continuation of the first. Replay rehydrates the Effection tree. Ephemeral structure executes again; durable observations and mutations restore. +**Permission is a question about work that is about to happen.** It is asked +inside the live performer, as its first step, so a completed effect restores +what it recorded without asking current policy and without entering the +performer that would ask. Only new work meets the policy in force now. A check +placed around the effect instead would re-decide an operation that has already +been published: a run continued under a narrower policy would contradict its own +journal, and one continued under a wider policy would ask for permission it no +longer needs. A denial of new work is an ordinary live outcome — the sanitized +`permission-denied` refusal, published like any other, changing no target and +capturing the root the run already had. + | Construct | Partial replay behavior | | --- | --- | | implicit Workspace | reattach the same run-owned Workspace | From 65c4e5f87118c720ed8c6cb59dd63b4f1df6f9f3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 13 Oct 2026 22:45:05 -0400 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=94=92=20A=20ceiling=20is=20the=20who?= =?UTF-8?q?le=20request,=20and=20the=20Plan=20grant=20is=20proved=20in=20p?= =?UTF-8?q?lace=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review blockers, and the first was a real escape. The per-entry Fetch ceiling compared the destination and the method and exempted headers, on the reasoning that the host which stated the ceiling is the host whose middleware signs what leaves it. That reasoning does not hold: contextual middleware is a shared route available in the consuming scope, and its presence is not evidence that the host approved what it did. The reviewer's probe showed an admitted request leaving with `x-bound: changed` and succeeding. The comparison is now the whole request, normalized the way an authored one is — `GET` by default, header names lowercased and sorted — and taken **as a string at capture**. That matters as much as including the headers: the request travels through middleware by reference, so comparing against the entry's own objects would let an in-place edit move both sides together and still "match". For the same reason the transport is handed a copy of the admitted request rather than the ceiling's own, so an edit cannot change what a later invocation of that entry treats as admitted. Two names that collide when lowercased produce no signature and match nothing, which is the authored rule's fail-closed half. Three regressions beside PRM7, each with its own control: a header replaced through delegation, a header edited in place, and an in-place edit that stays inside the ceiling — whose next invocation still sends what the host admitted. Removing the headers from the signature fails two of them; handing the transport the ceiling's own object fails the third. The second blocker was evidence, not behavior. The frozen U2-10 placements ask for the Plan boundary to be proved through the real private bodies and the real guard, and `createPlanAccess()` driven directly cannot do that. Five cases now do, using the production declaration and the existing fixtures: - **PI24** holds an information read open inside the provider with an operation handshake, and a concurrent sibling task of the writer frame asks through the real guard: refused, reaching no provider. The same frame is refused before any turn and again at review time, which is strictly after the information phase settled. The delegated read is the only operation the provider saw. - **PI25** invokes the real private information body with a claimant that refuses the way canonical execution's does for an invocation it did not issue; access does not open. The control — a claimant that answers — gets past the claim and fails later at its projection, so what is asserted is the order. - **PI26** drives a throwing Files provider through Plan: authorship ends with one prompt, no retry turn and no Plan. - **PI27** cancels a read that has entered the provider while a cleanup registered with it holds. `joined` is read *while* the cleanup is held, which is the only moment that distinguishes waiting from not waiting. - **PI28** writes two `` elements in one document, the first inside a read-only region: its request is denied and earns one more turn, and the second, outside the region, performs its request under the restored enclosing allowance. PI20 gains the standalone surface, where one middleware observes the request and another nearer the provider substitutes what is read. PLA1–PLA6 stay as mechanism controls for the scope relationships a document cannot arrange. The two new harness hooks are test-support only: one runs work inside the writer frame at its own observation point, after the refusals are installed, and one runs at review time. --- .../cli/tests/plan-command-document.test.ts | 78 +++- packages/cli/tests/plan-component.test.ts | 362 +++++++++++++++++- packages/cli/tests/support/plan-harness.ts | 44 ++- packages/core/src/fragment-capabilities.ts | 77 +++- .../shared-operation-permissions.test.ts | 243 +++++++++++- specs/executable-mdx-spec.md | 3 + specs/plan-command-spec.md | 7 +- 7 files changed, 791 insertions(+), 23 deletions(-) diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index aba7e9852..c454259d5 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -23,6 +23,7 @@ import { expect } from "@executablemd/test-support/expect"; import { ensure, scoped, sleep, spawn, useScope, withResolvers } from "effection"; import type { Operation } from "effection"; import { forEach } from "@effectionx/stream-helpers"; +import { API } from "@executablemd/runtime"; import { ensureDir, rm } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; @@ -164,6 +165,13 @@ interface RunOptions { * Absent leaves the real host provider in place. */ files?: RecordedFiles; + /** + * Anything else this case installs inside the execution. + * + * Runs after the recorder, so middleware installed here sits between the + * document and that provider — which is where a document's own handler goes. + */ + installing?: () => Operation; /** * The vocabulary this execution describes. * @@ -243,9 +251,18 @@ function* runDocument(options: RunOptions = {}): Operation { // A case that supplies its own recorder gets that instead, which is // how a row proves which operation a read actually reached. evaluation: options.evaluation ?? ordinaryEvaluationProfile(), - ...(options.files === undefined + ...(options.files === undefined && options.installing === undefined ? {} - : { install: () => useRecordedFiles(options.files!) }), + : { + *install(): Operation { + if (options.files !== undefined) { + yield* useRecordedFiles(options.files); + } + if (options.installing !== undefined) { + yield* options.installing(); + } + }, + }), ...(options.componentAnswers === undefined ? {} : { componentAnswers: [...options.componentAnswers] }), @@ -774,6 +791,63 @@ describe("Tier PI — read-only information requests", () => { ], }; + /** + * The standalone surface takes the same route, and middleware decides it. + * + * `xmd plan` installs the same ordinary profile `xmd run` does, so an + * information request here is the ordinary component performing the ordinary + * operation — observable from both sides of the provider. The document's own + * handler sees what the fragment asked for; the handler installed nearer the + * provider substitutes what is actually read; and the findings the next turn + * is given carry that substituted answer rather than the request's. + */ + it("PI20: the standalone request crosses enclosing and nearer middleware", function* () { + // The requested file does not exist; the substituted one does. A finding + // naming it can only have come from the transformed request. + const files = recordedFiles({ "renamed.md": "the renamed note\n" }); + const enclosing: string[] = []; + const run = yield* runDocument({ + turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, + *installing(): Operation { + // Nearer the provider, and transforming. + yield* API.Files.around( + { + *globFiles([input], next) { + return yield* next({ ...input, include: ["renamed.md"] }); + }, + *readTextFile([input], next) { + return yield* next({ ...input, path: "renamed.md" }); + }, + }, + { at: "min" }, + ); + // Where a document's own handler goes, observing only. + yield* API.Files.around({ + *globFiles([input], next) { + enclosing.push(`glob ${input.include.join(",")}`); + return yield* next(input); + }, + *readTextFile([input], next) { + enclosing.push(`read ${input.path}`); + return yield* next(input); + }, + }); + }, + }); + + expect(run.failure).toBe(undefined); + // What the request asked for. + expect(enclosing).toEqual(["glob notes.md", "read notes.md"]); + // What the provider was actually asked to perform. + expect(files.performed).toEqual(["glob renamed.md", "read renamed.md"]); + // And what the next turn was given is that answer. + const followUp = run.prompts[1] ?? ""; + expect(followUp).toContain("the renamed note"); + expect(followUp).toContain("renamed.md"); + }); + it("PI2, PI12: findings reach the next turn, and default progress says only how many", function* () { const files = recordedFiles({ "notes.md": "the retained note\n" }); const run = yield* runDocument({ diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 1fdcdad14..53095b861 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, scoped, spawn, until } from "effection"; +import { ensure, scoped, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; @@ -31,7 +31,7 @@ import { useNormalizedOutput, } from "@executablemd/core"; import type { Json, SyntaxSymbols } from "@executablemd/core"; -import { registerComponents, validateDocument } from "@executablemd/core"; +import { content, registerComponents, validateDocument } from "@executablemd/core"; import { executeInstalled, fileReadEntry, @@ -62,6 +62,16 @@ import type { StructuralValidation } from "../src/plan-component.ts"; import { syntaxSymbols } from "../src/syntax.ts"; import { PLAN_DOCUMENT, readPackagedDocument } from "../src/packaged-document.ts"; +/** What one operation failed with, so a row can drive a cancellation itself. */ +function* raised(operation: Operation): Operation { + try { + yield* operation; + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + const ROOT = "document.md"; /** A Plan the coding agent replies with, and that admission accepts. */ @@ -640,6 +650,354 @@ describe("Tier PC — in an ordinary document", () => { }); }); + /** A request that reads a file, so the recorder's hold fires on it. */ + const READ_REQUEST = '\n\n'; + + /** A harness answering a file read first and a Plan second. */ + function* reading_( + root: string, + hooks: { + readonly insideFrame?: () => Operation; + readonly atReview?: () => Operation; + } = {}, + ): Operation { + const harness = yield* planDeclarationHarness({ + surface: "component", + planWriterRoot: root, + ...(hooks.insideFrame === undefined ? {} : { insideFrame: hooks.insideFrame }), + ...(hooks.atReview === undefined ? {} : { atReview: hooks.atReview }), + }); + harness.fake.script({ reply: READ_REQUEST }); + harness.fake.script({ reply: PLAN }); + harness.script({ decision: "Approve" }); + return harness; + } + + /** What an ambient read inside the frame answered with. */ + function* ambient(): Operation { + try { + yield* API.Files.operations.readTextFile({ cwd: ".", path: "notes.md" }); + return "granted"; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + /** + * The frame refuses its own reads before, during and after the one it + * delegates. + * + * The grant belongs to the scope the projection runs in, so the discriminating + * case is a *concurrent* one: an ordinary sibling task of the frame, running + * while the information read is held open inside the provider. It asks + * through the real guard — the frame's own installed Files middleware — and + * must reach no provider at all. + * + * The handshake is what makes it an ordering claim rather than a timing + * one: the provider resolves `entered` from inside the read, the sibling + * asks and then releases it, and the read returns afterwards. + */ + it("PI24: a concurrent writer sibling is refused while the information read is held", function* () { + yield* useWorkingDirectory(function* () { + const entered = withResolvers(); + const released = withResolvers(); + const asked: string[] = []; + const files = recordedFiles( + { "notes.md": "the retained note\n" }, + { + *hold(path) { + if (path === "notes.md") { + entered.resolve(); + yield* released.operation; + } + }, + }, + ); + + const run = yield* runDocument({ + source: SOURCE, + harness: yield* reading_(yield* planWriterRoot(), { + *insideFrame(): Operation { + // Before any turn, in the frame itself: the frame reads nothing. + asked.push(`before: ${yield* ambient()}`); + yield* spawn(function* () { + yield* entered.operation; + asked.push(`during: ${yield* ambient()}`); + released.resolve(); + }); + }, + *atReview(): Operation { + // Strictly after the information phase settled: the review is of + // the draft the *next* turn produced. + asked.push(`after: ${yield* ambient()}`); + }, + }), + reviews: [], + evaluation: reading(), + files: files, + }); + + expect(run.failure).toBe(undefined); + expect(run.output).toContain(`got: ${PLAN}`); + // The delegated read happened, exactly once, and it is the only + // operation the provider ever saw. + expect(files.performed).toEqual(["read notes.md"]); + // And all three ambient attempts were refused by the frame's own guard. + expect(asked).toEqual([ + "before: xmd plan asked for a file, which the Plan writer profile grants to nothing", + "during: xmd plan asked for a file, which the Plan writer profile grants to nothing", + "after: xmd plan asked for a file, which the Plan writer profile grants to nothing", + ]); + }); + }); + + /** + * And access does not open for something merely shaped like the phase. + * + * The grant is opened after `claim(invocation)`, which is canonical + * execution's own authentication: an invocation it did not issue is refused + * there, before any bookkeeping exists. This drives the **real private + * body** off the declaration the production path builds, with a claimant + * that refuses the way core's does for a forged invocation — and the + * control beside it, a claimant that answers, shows the body gets past the + * claim and on to its projection. So the ordering is what is asserted + * rather than the fact that something threw. + */ + it("PI25: the private information body opens no access without its claimant", function* () { + yield* useWorkingDirectory(function* () { + const harness = yield* planDeclarationHarness({ + surface: "component", + planWriterRoot: yield* planWriterRoot(), + }); + const information = harness.declaration.privates?.find( + (component) => component.name === "PlanInformation", + ); + expect(information).toBeDefined(); + const forged = { hasContent: () => true }; + + // The claimant canonical execution would hand this body for an + // invocation it did not issue: it refuses. + const refused = information?.factory( + // deno-lint-ignore require-yield + function* (): Operation { + throw new Error("this component was called without the invocation the engine issued"); + }, + ); + let unauthenticated = ""; + try { + yield* refused?.({}, forged) ?? ("" as unknown as Operation); + } catch (error) { + unauthenticated = error instanceof Error ? error.message : String(error); + } + expect(unauthenticated).toContain("without the invocation the engine issued"); + + // The control: a claimant that answers. The body now gets past the + // claim — and fails further on, at the projection it has no content + // host for — which is how we know the refusal above was the claim and + // not something earlier. + const allowed = information?.factory( + // deno-lint-ignore require-yield + function* (): Operation { + return "claimed"; + }, + ); + let authenticated = ""; + try { + yield* allowed?.({}, forged) ?? ("" as unknown as Operation); + } catch (error) { + authenticated = error instanceof Error ? error.message : String(error); + } + expect(authenticated).not.toBe(""); + expect(authenticated).not.toContain("without the invocation the engine issued"); + }); + }); + + /** + * A provider that throws is not a refused request. + * + * The recovery this workflow owns is narrow by design: an ordinary `Err` + * from a captured read is the fragment's own text being refused, and gets + * another turn. A provider that *raises* is infrastructure, and nothing the + * candidate rewrites fixes it — so authorship ends, with no following turn + * and no Plan. + */ + it("PI26: a throwing Files provider ends authorship with no retry turn", function* () { + yield* useWorkingDirectory(function* () { + const files = recordedFiles({ "notes.md": "the retained note\n" }); + const run = yield* runDocument({ + source: SOURCE, + harness: yield* reading_(yield* planWriterRoot()), + reviews: [], + evaluation: reading(), + files: { + ...files, + // deno-lint-ignore require-yield + *readTextFile(): Operation { + throw new Error("the Files provider failed"); + }, + }, + }); + + expect(run.failure).not.toBe(undefined); + // One prompt, and no second one: the request was never turned into + // retry context. + expect(run.harness.fake.prompts).toHaveLength(1); + expect(run.output).not.toContain(PLAN); + }); + }); + + /** + * Cancelling an information read, with its cleanup held open. + * + * The grant is live exactly across `tryContent()` and the teardown the + * projection joins, so the interesting interruption is one that lands + * *inside* the provider: the read is in flight, a cleanup registered with it + * holds, and the halt may not be allowed to complete until that cleanup + * finishes. `joined` read while the cleanup is still held is what proves it + * waited — a flag checked afterwards would be true either way. + * + * What it shows about access is what a cancelled run can show: nothing + * further is performed under the grant. The retained-guard case — a guard + * answering no once its frame has ended — is Tier PLA's, where a scope can + * outlive what it was asking about. + */ + it("PI27: cancelling an entered information read waits for its cleanup", function* () { + yield* useWorkingDirectory(function* () { + const entered = withResolvers(); + const cleanupEntered = withResolvers(); + const cleanupReleased = withResolvers(); + const order: string[] = []; + let joined = false; + + const files = recordedFiles( + { "notes.md": "the retained note\n" }, + { + *hold(path) { + if (path !== "notes.md") { + return; + } + // Registered with the read, so it runs as the read unwinds. + yield* ensure(function* () { + order.push("cleanup entered"); + cleanupEntered.resolve(); + yield* cleanupReleased.operation; + order.push("cleanup finished"); + }); + entered.resolve(); + // The read never settles on its own: the halt is what ends it. + yield* suspend(); + }, + }, + ); + + const harness = yield* reading_(yield* planWriterRoot()); + const running = yield* spawn(() => + raised( + runDocument({ + source: SOURCE, + harness, + reviews: [], + evaluation: reading(), + files, + }), + ), + ); + + yield* entered.operation; + const halting = yield* spawn(function* () { + yield* running.halt(); + joined = true; + }); + yield* cleanupEntered.operation; + // Read while the cleanup is still held: the halt is waiting for it. + order.push(`joined while held: ${joined}`); + cleanupReleased.resolve(); + yield* halting; + order.push(`joined after: ${joined}`); + + expect(order).toEqual([ + "cleanup entered", + "joined while held: false", + "cleanup finished", + "joined after: true", + ]); + // The read that was cancelled is the only operation the provider ever + // saw: nothing continued under the grant afterwards. + expect(files.performed).toEqual(["read notes.md"]); + }); + }); + + /** + * The restriction belongs to the region, and leaving it restores what was + * there. + * + * One document, two `` elements: the first written inside a read-only + * region and the second outside it. The first one's request is denied and + * becomes one more turn; the second one's is performed. That transition is + * the thing PI21 cannot show — it installs a restriction for the whole + * execution — and it is scoped permission behaving the way every other + * region does. The general ordering proof stays Tier PM's. + */ + it("PI28: a Plan outside the narrower region uses the restored allowance", function* () { + yield* useWorkingDirectory(function* () { + const files = recordedFiles({ "notes.md": "the retained note\n" }); + const harness = yield* planDeclarationHarness({ + surface: "component", + planWriterRoot: yield* planWriterRoot(), + }); + // Four turns: a denied request and a Plan for the first element, a + // performed request and a Plan for the second. + harness.fake.script({ reply: READ_REQUEST }); + harness.fake.script({ reply: PLAN }); + harness.fake.script({ reply: READ_REQUEST }); + harness.fake.script({ reply: PLAN }); + harness.script({ decision: "Approve" }); + harness.script({ decision: "Approve" }); + + const run = yield* runDocument({ + source: [ + "", + "", + 'Write a program.', + "", + "", + "", + 'Write a program.', + "", + ].join("\n"), + harness, + reviews: [], + evaluation: reading(), + files: files, + *installing(): Operation { + yield* registerComponents([ + { + name: "ReadOnlyRegion", + origin: "test://regions", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + yield* Permissions.around({ + files: ([operation], next) => (operation === "read" ? false : next(operation)), + }); + return yield* content(); + }, + }, + ]); + }, + }); + + expect(run.failure).toBe(undefined); + // Exactly one read reached the provider: the second element's. + expect(files.performed).toEqual(["read notes.md"]); + const prompts = run.harness.fake.prompts; + expect(prompts).toHaveLength(4); + // Inside the region: refused, and the refusal is what the next turn got. + expect(prompts[1] ?? "").toContain("That request was refused"); + // Outside it: performed, and the findings are what the next turn got. + expect(prompts[3] ?? "").toContain("the retained note"); + }); + }); + it("PI12: an embedded request emits no command progress into the document", function* () { yield* useWorkingDirectory(function* () { const files = recordedFiles({ "notes.md": "the retained note\n" }); diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index 943e5320c..8703d6b9e 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -330,6 +330,25 @@ export function* planDeclarationHarness(options: { /** Absent leaves the harness with no stack at all, as `xmd test` has none. */ stack?: PlanWriterStack | null; store?: FakeStore; + /** + * Work this case runs *inside* the Plan writer frame, before any turn. + * + * The one place a case can stand where the frame's own refusals apply to it: + * the frame's own observation point, reached after the capability refusals + * are installed and before any turn. A task spawned here is an ordinary + * sibling of the Component's phases and reaches the real guard rather than a + * model of it, which is how a concurrent writer read is attempted while an + * information projection is open. + */ + insideFrame?: () => Operation; + /** + * Work this case runs inside the frame when a review is asked. + * + * Strictly after the information phase settled, because the review is of the + * draft a later turn produced — which is what makes it the "afterwards" + * position for an ambient read. + */ + atReview?: () => Operation; }): Operation { const fake = createFakeAcp(); const checked: string[] = []; @@ -357,16 +376,39 @@ export function* planDeclarationHarness(options: { }, ), planWriterRoot: options.planWriterRoot, + // Where the frame reports itself, which is *after* its capability refusals + // are installed and before any turn — so work a case runs here is work the + // frame's own guard answers for. + ...(options.insideFrame === undefined + ? {} + : { + *observePlanWriter(): Operation { + yield* options.insideFrame!(); + }, + }), ...(options.session === undefined ? {} : { session: options.session }), ...(options.explicitSession === undefined ? {} : { explicitSession: options.explicitSession }), ...(options.verbose === undefined ? {} : { verbose: options.verbose }), host: yield* useScope(), + // Where the frame reports itself, which is *after* its capability refusals + // are installed and before any turn — so work a case runs here is work the + // frame's own guard answers for. + ...(options.insideFrame === undefined + ? {} + : { + *observePlanWriter(): Operation { + yield* options.insideFrame!(); + }, + }), + *installElicitation() { yield* Elicitation.around( { - // deno-lint-ignore require-yield *elicit([request], _next) { reviews.push(request); + if (options.atReview !== undefined) { + yield* options.atReview(); + } const answer = answers.shift(); if (answer === undefined) { throw new Error("the case scripted no answer for this review"); diff --git a/packages/core/src/fragment-capabilities.ts b/packages/core/src/fragment-capabilities.ts index 77287fa76..b10899ae5 100644 --- a/packages/core/src/fragment-capabilities.ts +++ b/packages/core/src/fragment-capabilities.ts @@ -388,6 +388,22 @@ function boundedRequests( inner: FunctionComponent, requests: readonly FetchRequest[], ): FunctionComponent { + // The baseline, taken once and kept as strings. A comparison against the + // objects this entry hands the transport would be a comparison against + // something the chain can edit: the request travels through middleware by + // reference, so an in-place edit would rewrite both what the restriction + // checks and what a later invocation of this entry treats as admitted. + // Strings cannot be edited from anywhere. + const ceiling: ReadonlySet = new Set( + requests.flatMap((request) => { + // An admitted request cannot be ambiguous — `admitHeaders` refuses two + // spellings of one name before a ceiling is ever normalized — so this + // drops nothing in practice and refuses rather than guesses if it ever + // could. + const held = signature(request.url, request.method, request.headers); + return held === undefined ? [] : [held]; + }), + ); return function* performed(props: Record): Operation { const url = String(props.url); const admitted = requests.find((request) => request.url === url); @@ -399,8 +415,10 @@ function boundedRequests( const expansion = yield* getExpansion(); const retained = yield* scoped(function* () { yield* Permissions.around({ - fetch: ([input, init], next) => - permitted(requests, input, init) ? next(input, init) : false, + fetch: ([input, init], next) => { + const outgoing = signatureOf(input, init); + return outgoing !== undefined && ceiling.has(outgoing) ? next(input, init) : false; + }, }); return yield* persistFetch( { @@ -408,7 +426,10 @@ function boundedRequests( ...(expansion.position === undefined ? {} : { position: expansion.position }), }, admitted, - () => observeFetch(admitted), + // A copy per invocation, for the same reason the baseline is a string: + // what goes down the chain is editable by anything in it, and this + // entry's ceiling must not be what gets edited. + () => observeFetch({ ...admitted, headers: { ...admitted.headers } }), ); }); // The status rule is the authored one: a fragment binds nothing, so a @@ -425,20 +446,44 @@ function boundedRequests( } /** - * Whether the request about to be sent is still one this entry admits. + * One request as the single string a ceiling is compared by. + * + * The destination, the method and the headers — the whole of what the host + * admitted. Normalized the way an authored request is: the method defaults to + * `GET`, header names are lowercased, and the names are sorted, so two + * spellings of one request produce one signature and comparison does not depend + * on the order a transformation happened to build its object in. * - * The destination and the method, which are what a ceiling is about: a - * transformation that sends the request somewhere else, or sends it as a - * different method, is performing a request the host never admitted. Headers - * are deliberately not compared — the host that stated this ceiling is the same - * host whose middleware signs and routes what leaves it, and a restriction that - * refused its own authentication header would make that unusable. + * Two names that become the same key answer `undefined` rather than one of + * them. An authored request is refused outright for that ambiguity + * (`fetch-request.ts`), and a request built by middleware says two things about + * one header; a signature that silently kept either would be deciding which. + * `undefined` matches no ceiling, so the ambiguity fails closed. */ -function permitted( - requests: readonly FetchRequest[], +function signature( + url: string, + method: string, + headers: Record | undefined, +): string | undefined { + const held = new Map(); + for (const [name, value] of Object.entries(headers ?? {})) { + if (typeof value !== "string") { + return undefined; + } + const key = name.toLowerCase(); + if (held.has(key)) { + return undefined; + } + held.set(key, value); + } + const ordered = [...held.keys()].sort().map((key) => [key, held.get(key)]); + return JSON.stringify([url, method, ordered]); +} + +/** The signature of the request that is actually about to be sent. */ +function signatureOf( input: string, - init: { readonly method?: string } | undefined, -): boolean { - const method = init?.method ?? "GET"; - return requests.some((request) => request.url === input && request.method === method); + init: { readonly method?: string; readonly headers?: Record } | undefined, +): string | undefined { + return signature(input, init?.method ?? "GET", init?.headers); } diff --git a/packages/core/tests/shared-operation-permissions.test.ts b/packages/core/tests/shared-operation-permissions.test.ts index 2255cbe6a..c7e78f65b 100644 --- a/packages/core/tests/shared-operation-permissions.test.ts +++ b/packages/core/tests/shared-operation-permissions.test.ts @@ -140,6 +140,25 @@ function* useRegions(): Operation { return yield* content(); }, }, + { + name: "Editing", + origin: "test://regions", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn(): Operation { + // Changes the outgoing headers *in place* rather than building a new + // object, which is the shape a comparison against the request's own + // objects would be defeated by. + yield* API.Fetch.around({ + *fetch([input, init], next) { + if (init?.headers !== undefined) { + init.headers["x-bound"] = "changed"; + } + return yield* next(input, init); + }, + }); + return yield* content(); + }, + }, { name: "DirectRead", origin: "test://regions", @@ -203,6 +222,8 @@ function* refusal(fixture: Fixture, source: string): Operation { interface Accepted { readonly method: string; readonly path: string; + /** The one bounded header these rows are about, as it arrived. */ + readonly bound?: string; } interface Loopback { @@ -222,7 +243,12 @@ function useLoopback(): Operation { return resource(function* (provide) { const accepted: Accepted[] = []; const server: Server = createServer((request: IncomingMessage, response: ServerResponse) => { - accepted.push({ method: request.method ?? "", path: request.url ?? "" }); + const bound = request.headers["x-bound"]; + accepted.push({ + method: request.method ?? "", + path: request.url ?? "", + ...(typeof bound === "string" ? { bound } : {}), + }); response.writeHead(200, { "content-type": "text/plain" }); response.end("from the network"); }); @@ -579,6 +605,221 @@ describe("Tier PRM — authored, generated and direct operations under one restr expect(accepted).toEqual([]); }); + /** + * The ceiling is the whole request, and headers are part of it. + * + * PRM7 rewrites the destination, which any comparison catches. This is the + * case a URL-and-method comparison lets through: the element asks for exactly + * the admitted request, and contextual middleware changes one admitted header + * on the way out. The host that stated the ceiling stated that header, so a + * transformation that sends a different one is sending a request this entry + * never admitted — and middleware being available in the consuming scope is + * not evidence the host approved it. + */ + it("PRM7b: a changed admitted header is refused at the transport", function* () { + const fixture = yield* useFixture(); + let control: readonly Accepted[] = []; + let afterChange: readonly Accepted[] = []; + let failed = ""; + + yield* scoped(function* () { + const server = yield* useLoopback(); + yield* useHost(fixture); + yield* useRegions(); + const admitted = { + url: `${server.origin}/admitted`, + method: "GET", + headers: { "x-bound": "admitted" }, + }; + const source = + `\\n\\n'} />\n`; + const admitting: ExecutionInstallation = { + evaluation: { + read: [fileReadEntry(), globReadEntry(), syntaxReadEntry(), fetchEntry([admitted])], + }, + }; + + // The positive control first, in the same scope and against the same + // server: the element, the ceiling and the header all agree, and the + // request goes out. + yield* collect( + yield* executeInstalled( + { ...retainedSource(ROOT_PATH, source), stream: new InMemoryStream() }, + [admitting], + ), + ); + control = [...server.accepted]; + + yield* scoped(function* () { + // Ordinary `API.Fetch` middleware, changing one admitted header and + // delegating everything else unchanged. + yield* API.Fetch.around({ + *fetch([input, init], next) { + return yield* next(input, { + ...init, + headers: { ...init?.headers, "x-bound": "changed" }, + }); + }, + }); + try { + yield* collect( + yield* executeInstalled( + { ...retainedSource(ROOT_PATH, source), stream: new InMemoryStream() }, + [admitting], + ), + ); + } catch (error) { + failed = error instanceof Error ? error.message : String(error); + } + }); + afterChange = [...server.accepted]; + }); + + expect(control).toEqual([{ method: "GET", path: "/admitted", bound: "admitted" }]); + expect(failed).toContain("not permitted in this scope"); + // Nothing further reached the transport: the refusal is a request that was + // never sent rather than one the server answered. + expect(afterChange).toEqual(control); + }); + + /** + * And the baseline is not the object the chain was handed. + * + * The request travels through middleware by reference, so a handler can edit + * the outgoing headers *in place* rather than building a new object. Against + * a comparison that read those same objects both sides would move together, + * the edited request would still "match", and it would be sent — so the + * refusal below is exactly the assertion that the ceiling is held by value + * taken at capture and the transport is handed a copy. + * + * The permitted element ahead of it shares one capture with it, which is what + * makes it evidence rather than decoration: the entry is demonstrably working + * for the request the host admitted, in this very run, and the edit is what + * the terminal refuses. + */ + it("PRM7c: an in-place edit is refused, and the admitted request in the same capture is not", function* () { + const fixture = yield* useFixture(); + let accepted: readonly Accepted[] = []; + let failed = ""; + + yield* scoped(function* () { + const server = yield* useLoopback(); + yield* useHost(fixture); + yield* useRegions(); + const admitted = { + url: `${server.origin}/admitted`, + method: "GET", + headers: { "x-bound": "admitted" }, + }; + const element = + `\\n\\n'} />`; + + try { + yield* collect( + yield* executeInstalled( + { + ...retainedSource( + ROOT_PATH, + // Permitted first — a generated refusal ends the run, so the + // element that proves the entry works has to be the one ahead + // of it. + [element, "", "", "", element, "", "", ""].join("\n"), + ), + stream: new InMemoryStream(), + }, + [ + { + evaluation: { + read: [ + fileReadEntry(), + globReadEntry(), + syntaxReadEntry(), + fetchEntry([admitted]), + ], + }, + }, + ], + ), + ); + } catch (error) { + failed = error instanceof Error ? error.message : String(error); + } + accepted = [...server.accepted]; + }); + + expect(failed).toContain("not permitted in this scope"); + // One request: the admitted one. The edited element sent nothing, so the + // edit neither reached the network nor became something this entry admits. + expect(accepted).toEqual([{ method: "GET", path: "/admitted", bound: "admitted" }]); + }); + + /** + * The half a refusal cannot show: what the *next* invocation sends. + * + * An in-place edit that happens to stay inside the ceiling is permitted, and + * that is the case which reveals whether the edit reached the entry's own + * copy of the admitted request. Both headers are admitted here, so the edited + * element goes out as `changed`; the element after it, outside the editing + * region, asks for the same entry again. It sends `admitted` — so the edit + * changed the request on its way out and nothing else. + * + * Against an entry that handed the transport its own ceiling object, the edit + * would have landed in that object and this second element would send + * `changed` too. + */ + it("PRM7d: an in-place edit does not change what the next invocation sends", function* () { + const fixture = yield* useFixture(); + let accepted: readonly Accepted[] = []; + + yield* scoped(function* () { + const server = yield* useLoopback(); + yield* useHost(fixture); + yield* useRegions(); + const url = `${server.origin}/admitted`; + const element = + `\\n\\n'} />`; + + yield* collect( + yield* executeInstalled( + { + ...retainedSource( + ROOT_PATH, + ["", "", element, "", "", "", element, ""].join("\n"), + ), + stream: new InMemoryStream(), + }, + [ + { + evaluation: { + read: [ + fileReadEntry(), + globReadEntry(), + syntaxReadEntry(), + // Both spellings admitted, so the edit below is inside the + // ceiling and the row is about the copy rather than about the + // refusal. + fetchEntry([ + { url, method: "GET", headers: { "x-bound": "admitted" } }, + { url, method: "GET", headers: { "x-bound": "changed" } }, + ]), + ], + }, + }, + ], + ), + ); + accepted = [...server.accepted]; + }); + + expect(accepted).toEqual([ + { method: "GET", path: "/admitted", bound: "changed" }, + { method: "GET", path: "/admitted", bound: "admitted" }, + ]); + }); + it("PRM5: a generated Dir scopes what follows it and restores the directory", function* () { const fixture = yield* useFixture(); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 62fbaa986..ec23c5450 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11889,6 +11889,9 @@ written after it is what makes the outside observable. | PRM5 | A generated directory | `` inside a fragment creates the directory, scopes the file written inside it, and restores the enclosing directory for the file written after it | | PRM6 | The admitted request | A generated `` performs its entry's admitted request and a real loopback accepts exactly it | | PRM7 | The ceiling at the transport | Middleware rewriting the destination off the entry's ceiling is refused by the terminal with the fixed sentence, and the transport accepts nothing | +| PRM7b | The whole request is the ceiling | The element asks for exactly the admitted request and middleware changes one admitted header: the terminal refuses with the fixed sentence and the transport accepts nothing, while the same element without the change is accepted with the admitted header | +| PRM7c | A baseline nothing can edit | A handler editing the outgoing headers *in place* is refused the same way, and the admitted request in the same capture is accepted — so the comparison is held by value taken at capture rather than read from the object the chain was handed | +| PRM7d | What the next invocation sends | An in-place edit that stays inside the ceiling is permitted, and the element after it sends the request the host admitted rather than the edited one | ### Tier FF — Files infrastructure failure diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index ea794220c..3354b665a 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -1143,8 +1143,13 @@ neither observation never interpreted what it wrote. | PI9 | Distribution | The embedded request-to-approved-Plan journey executes through source, emitted npm and compiled installations. The command journey executes through the in-process production command assembly and exact packaged command document. Npm and compiled controls verify packaged command and Plan assets, Plan identity and digest, its exact text contract and private closure, and the canonical protected Syntax/Evaluate tier | | PI10 | Recoverable versus terminal | A malformed request and an ordinary read `Err` are each classified by core as a refused generated request, which `` alone turns into one safe retry context; missing or broken profile or protected route, a throwing Files provider, stale or corrupt history, journal or secret failure, unexpected runtime failure, teardown failure and outer cancellation each stop authorship. Public `` still throws under ordinary use | | PI11 | Language, not permission | Branching, binding and bounded iteration compose with admitted reads under ordinary rules, and a prohibited component in an untaken branch refuses the whole fragment with zero reads | -| PI20 | The shared route, from both sides | One middleware the document installed observes the request the fragment made, a second installed nearer the provider transforms it, and the findings the next turn receives carry the transformed answer — so an information request is the ordinary operation and not a private one | +| PI20 | The shared route, from both sides | On both surfaces: one middleware the document installed observes the request the fragment made, a second installed nearer the provider transforms it, and the findings the next turn receives carry the transformed answer — so an information request is the ordinary operation and not a private one | | PI21 | Current policy decides it | A read restriction the enclosing scope installed denies the request before the provider performs anything; the refusal becomes exactly one more turn carrying the sanitized sentence, and authorship still finishes. The ordering half — a descendant answering `true` cannot defeat an enclosing `false` — is Tier PM's | | PI22 | The writer frame still refuses everything else | Inside the frame and outside any projection, a read, a temporary directory, a semantic directory ensure, a command and the network are each refused with the frame's own fixed sentence | +| PI24 | Isolation around a live projection | With an information read held open inside the provider, a concurrent sibling task of the writer frame asks through the real guard and is refused, reaching no provider; the same frame is refused before any turn and again at review time, strictly after the information phase settled; the delegated read is the only operation the provider ever saw | +| PI25 | Authenticity at the private body | The real private information body, built from the production declaration, refuses an invocation its claimant does not authenticate and opens no access; with a claimant that answers, the same body gets past the claim and fails later at its projection | +| PI26 | A provider that raises | A throwing Files provider ends authorship with one prompt and no retry turn, and no Plan | +| PI27 | Cancelling an entered read | A halt landing inside the provider runs the cleanup registered with the read and does not complete while it is held — read while it is held, not afterwards — and the cancelled read is the only operation performed | +| PI28 | The region, and leaving it | One document with two `` elements: the first inside a read-only region has its request denied and gets one more turn; the second, outside it, performs the request through the restored enclosing allowance | | PI23 | The shape of the grant | A frame grants nothing until a projection opens; a projection grants its own scope and every scope created under it; a sibling created while one is open is granted nothing; one frame's projection does not satisfy another frame's guard; and a retained guard answers no once its frame has ended | | PI12 | Disclosure order | Observe default, verbose, journal and Agent prompts for success and refusal, then place a synthetic secret in a file read by an information request. Default output stays content-free; detailed findings follow settlement; the secret appears in no output, journal entry or Agent Prompt, starts no following turn, review or artifact, and ends with the existing terminal secret rejection | From ffada01d7aa4f84950c02f9ff62b6c74e1dc05af Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 13 Oct 2026 23:15:02 -0400 Subject: [PATCH 3/5] =?UTF-8?q?=E2=9C=85=20Keep=20the=20Plan=20grant's=20r?= =?UTF-8?q?evocation=20under=20test=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Planner's PASS came with a demonstration worth keeping: with the `finally` that closes the information grant removed, every committed Plan case stays green. A cancelled run takes the whole frame with it, and the concurrent writer sibling never held the grant — so neither of them can tell a grant that ends from one that never does. Their probe is kept here as PI29. What makes the moment reachable is where the secret scan runs: inside the information body, after `tryContent()` and its joined teardown have settled and after the grant was closed, and still in that body's own scope. A read attempted there comes from the scope that *was* granted, at a moment when it must not be any more, and the frame refuses it. The scanner factory is core's own seam for substituting a scanner — tests are its only consumer — so the row needs no product hook. The findings cross the scanner more than once, so the row asserts that the scan happened and that every attempt from that scope was refused rather than fixing a count it has no business fixing. Removing the close turns it red. --- packages/cli/tests/plan-component.test.ts | 72 ++++++++++++++++++++++- specs/plan-command-spec.md | 1 + 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 53095b861..aba7998bb 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -32,6 +32,10 @@ import { } from "@executablemd/core"; import type { Json, SyntaxSymbols } from "@executablemd/core"; import { content, registerComponents, validateDocument } from "@executablemd/core"; +// Core's own seam for substituting a scanner, which is how a case reaches the +// moment after an information projection settled and before its findings are +// disclosed. Deliberately not a package export: tests are its only consumer. +import { useSecretScannerFactory } from "../../core/src/secrets/policy.ts"; import { executeInstalled, fileReadEntry, @@ -673,6 +677,10 @@ describe("Tier PC — in an ordinary document", () => { return harness; } + /** What the writer frame refuses a read with. */ + const FRAME_REFUSAL = + "xmd plan asked for a file, which the Plan writer profile grants to nothing"; + /** What an ambient read inside the frame answered with. */ function* ambient(): Operation { try { @@ -744,9 +752,9 @@ describe("Tier PC — in an ordinary document", () => { expect(files.performed).toEqual(["read notes.md"]); // And all three ambient attempts were refused by the frame's own guard. expect(asked).toEqual([ - "before: xmd plan asked for a file, which the Plan writer profile grants to nothing", - "during: xmd plan asked for a file, which the Plan writer profile grants to nothing", - "after: xmd plan asked for a file, which the Plan writer profile grants to nothing", + `before: ${FRAME_REFUSAL}`, + `during: ${FRAME_REFUSAL}`, + `after: ${FRAME_REFUSAL}`, ]); }); }); @@ -998,6 +1006,64 @@ describe("Tier PC — in an ordinary document", () => { }); }); + /** + * The grant ends with the projection, observed from the scope that held it. + * + * This is the one assertion the rows above cannot make. A cancelled run and + * a refused writer sibling both leave the revocation untested: the sibling + * never held the grant, and a halted run takes the whole frame with it — so + * removing the `finally` that closes the grant keeps every other case here + * green. The Planner demonstrated exactly that, and this is their probe + * kept as a regression. + * + * What makes it possible is where the secret scan runs: inside the + * information body, after `tryContent()` and its joined teardown have + * settled and after the grant was closed, and still in that body's own + * scope. A read attempted there is a read from the scope that *was* + * granted, at a moment when it must not be any more. The scanner factory is + * core's own test seam for substituting a scanner, which is what makes that + * moment reachable without a product hook. + */ + it("PI29: the read grant has ended by the time the findings are scanned", function* () { + yield* useWorkingDirectory(function* () { + const observed: string[] = []; + const files = recordedFiles({ "notes.md": "the retained note\n" }); + + const run = yield* scoped(function* () { + yield* useSecretScannerFactory(() => ({ + *scan(text: string) { + // The findings, and not the draft: only they carry the note. + if (text.includes("the retained note")) { + observed.push(yield* ambient()); + } + return []; + }, + })); + return yield* runDocument({ + source: SOURCE, + harness: yield* reading_(yield* planWriterRoot()), + reviews: [], + evaluation: reading(), + files: files, + }); + }); + + expect(run.failure).toBe(undefined); + expect(run.output).toContain(`got: ${PLAN}`); + // The scan happened — this is not a case where nothing ran — and the + // read it attempted from the still-live body scope was refused. + // The findings cross the scanner more than once — the phase's own gate + // and the disclosure that follows it — so what is asserted is that the + // scan happened at all and that every attempt from that scope was + // refused, rather than a count this row has no business fixing. + expect(observed.length).toBeGreaterThan(0); + expect([...new Set(observed)]).toEqual([FRAME_REFUSAL]); + // One read, the projection's own: the probe's attempt reached no + // provider. + expect(files.performed).toEqual(["read notes.md"]); + }); + }); + it("PI12: an embedded request emits no command progress into the document", function* () { yield* useWorkingDirectory(function* () { const files = recordedFiles({ "notes.md": "the retained note\n" }); diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 3354b665a..01a46751a 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -1151,5 +1151,6 @@ neither observation never interpreted what it wrote. | PI26 | A provider that raises | A throwing Files provider ends authorship with one prompt and no retry turn, and no Plan | | PI27 | Cancelling an entered read | A halt landing inside the provider runs the cleanup registered with the read and does not complete while it is held — read while it is held, not afterwards — and the cancelled read is the only operation performed | | PI28 | The region, and leaving it | One document with two `` elements: the first inside a read-only region has its request denied and gets one more turn; the second, outside it, performs the request through the restored enclosing allowance | +| PI29 | The grant ends with the projection | A read attempted from the information body's *still-live* scope, at the moment its settled findings are scanned — after the projection and its joined teardown finished — is refused by the frame. It is the one case a cancelled run and a refused writer sibling cannot make: neither distinguishes a grant that is never closed | | PI23 | The shape of the grant | A frame grants nothing until a projection opens; a projection grants its own scope and every scope created under it; a sibling created while one is open is granted nothing; one frame's projection does not satisfy another frame's guard; and a retained guard answers no once its frame has ended | | PI12 | Disclosure order | Observe default, verbose, journal and Agent prompts for success and refusal, then place a synthetic secret in a file read by an information request. Default output stays content-free; detailed findings follow settlement; the secret appears in no output, journal entry or Agent Prompt, starts no following turn, review or artifact, and ends with the existing terminal secret rejection | From 2af2c8bd228d166d60246ce09c1a60123da4f3d8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 13 Oct 2026 23:47:11 -0400 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=90=9B=20A=20generated=20directory=20?= =?UTF-8?q?narrows=20the=20one=20it=20is=20written=20in=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI found two things the local focused set could not. Both reproduced locally before anything was edited. `WGAC10` asserts that a generated `` resolves against the directory its `` was written in, and it failed: nothing was at `/authored/nested/out.md`. The fixture models the site's directory by installing a contextual `cwd` handler — and it installed it at the **default `max`** order, while ``, `` and a workflow run's own Workspace root all install one at `min`. The ordering is the whole point of that choice: a `min` handler in an inner scope answers ahead of one further out, which is how a nested directory narrows an enclosing one, whereas a `max` handler answers ahead of every `min` and cannot be narrowed at all. So the fixture modelled the site in a way no run installs it. That was invisible while a generated fragment resolved its paths through the evaluator's own cursor — the cursor ignored the contextual chain entirely — and became load-bearing the moment generated work started using the same directory everything else does. The handler now installs at `min`, like the thing it models. This is a premise the ownership move untrued, not a weakened assertion: the row asserts exactly what it did, through the route the component actually takes. Worth stating plainly, because it is now uniform: an authored `` inside a `max`-installed working directory is equally unable to narrow it. Generated work is no longer the exception in either direction. The second was the `local/no-sync-filesystem` suppression audit, which requires the invariant to be stated within eight lines above the directive. The permission check this branch added to `temporaryDirectory()` pushed the function's doc comment out of that window. The reason now sits immediately above the suppression, which is where that rule wants it and where a reader of the next edit will find it. --- packages/runtime/host-files.ts | 4 ++++ .../tests/generated-agent-component.test.ts | 23 +++++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/runtime/host-files.ts b/packages/runtime/host-files.ts index 03a90812c..cb8373a9c 100644 --- a/packages/runtime/host-files.ts +++ b/packages/runtime/host-files.ts @@ -745,6 +745,10 @@ export function hostFilesHandler(options: HostFilesOptions = {}): FilesHandler { let created: string; try { + // Synchronous on purpose: nothing may suspend between naming the + // directory and registering the cleanup that removes it, and an + // asynchronous creation halted mid-flight would finish afterwards and + // leave a directory nothing owns. // oxlint-disable-next-line local/no-sync-filesystem created = mkdtempSync(join(options.temporaryRoot ?? tmpdir(), "xmd-tempdir-")); } catch (error) { diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index 304c17ceb..08594b03f 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -106,6 +106,16 @@ function runDocument( // the attachment and outside the execution, which is where the run's own // `` installs one — modelled by this host rather than imported, for the // same reason the directory entry above is stated rather than imported. + // + // At `min`, because that is what it is modelling. ``, `` and + // the run's own Workspace root all install the contextual directory there, + // and the ordering is the reason: a `min` handler installed in an inner scope + // answers ahead of one installed further out, which is how a nested + // directory narrows an enclosing one. A `max` handler here would answer + // ahead of *every* `min` and so could not be narrowed at all — it modelled + // the site's directory in a way no run installs one, which went unnoticed + // while a generated fragment resolved its paths through the evaluator's own + // cursor instead of the contextual directory. within?: string, ): Operation { return scoped(function* () { @@ -127,12 +137,15 @@ function runDocument( database, scoped(function* () { if (within !== undefined) { - yield* API.Env.around({ - // deno-lint-ignore require-yield - *cwd(): Operation { - return within; + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return within; + }, }, - }); + { at: "min" }, + ); } return yield* collect( yield* executeInstalled( From cdcfe328f1c8ec0cd7a62329a99d17b7edd60d1e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 14 Oct 2026 00:13:56 -0400 Subject: [PATCH 5/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20A=20trusted=20Plan=20r?= =?UTF-8?q?eads=20the=20way=20everything=20else=20does=20(#181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Product Owner changed the boundary: the packaged Plan workflow is trusted source, so it inherits ordinary host read access and the current Permissions rather than holding a read grant of its own. That removes the reason the writer frame tracked scope ancestry, and with it the whole mechanism — so this deletes more than it adds. `checkFilePath`, `readTextFile` and `globFiles` are no longer members of the frame's refusal table. They are not refused and re-permitted for one phase; they are simply absent, which means they take the route every other caller takes: the provider the entrypoint or the enclosing host installed, the middleware around it, and the effective `Permissions` of the scope asking. An enclosing read restriction therefore still denies them before target I/O, and the frame answers `Permissions` with nothing. A read before, during or after an information projection, and one from a concurrent trusted workflow task, are all the same ordinary operation. Gone with it: `plan-access.ts` and its tests, the `effection/experimental` Scope creation observer, the writer and grant WeakMaps, the opening and closing bookkeeping, the `holding()` wrapper that existed only to close a grant, and the `insideFrame`/`atReview` harness hooks whose only consumers were the retired rows. Nothing replaces them: no ancestry hook, context flag, request token, process-wide mode or private transport. What stays is what the amendment keeps. Both private factories still authenticate with `claim(invocation)` — that protects the private phase and the program it projects, and it decides nothing about permission. PlanInformation still projects with `tryContent()`, waits for the joined teardown, classifies only ordinary generated-request refusals and scans the settled findings before disclosure. The frame still refuses write, delete, directory ensure, temporary acquisition, Process, Fetch and Service wherever they are asked from, and the agent keeps its empty native-tool allowlist and strict native denial. What bounds the text an agent supplies was never a read denial here: it is the generated admission it runs under. Evidence follows the amended U2-10. `C5`/`PI22` now reads the caller's own file from inside the frame and asserts its exact bytes — a no-throw or a missing-file `Err` would pass against a provider that reached nothing — while keeping every mutation, command and network refusal in the same case. `PI20`, `PI21`, `PI25`, `PI26`, `PI27` and `PI28` are preserved; `PI24` and `PI29` are retired rather than relabelled, because they state the superseded boundary, and `PI23`/`PLA` go with the helper they exercised. One red control restores the old ambient read refusal and turns the amended row red. --- architecture.md | 26 ++- packages/cli/src/plan-access.ts | 185 --------------------- packages/cli/src/plan-component.ts | 53 ++---- packages/cli/src/plan-writer-profile.ts | 62 ++----- packages/cli/tests/plan-access.test.ts | 152 ----------------- packages/cli/tests/plan-component.test.ts | 175 ++----------------- packages/cli/tests/plan.test.ts | 41 +++-- packages/cli/tests/support/plan-harness.ts | 44 +---- specs/plan-command-spec.md | 60 ++++--- 9 files changed, 121 insertions(+), 677 deletions(-) delete mode 100644 packages/cli/src/plan-access.ts delete mode 100644 packages/cli/tests/plan-access.test.ts diff --git a/architecture.md b/architecture.md index 4958ea450..a69d83e9b 100644 --- a/architecture.md +++ b/architecture.md @@ -1683,12 +1683,24 @@ hosts, not the workflow host's independently installed profile. The constrained coding-Agent session remains separate. It receives prompt data, not native filesystem tools, a caller checkout, additional directories or a -permission escalation channel. Captured host Files operations answer admitted -XMD reads. The surrounding Plan writer frame's ambient Files refusal does not -replace or disable those explicitly admitted operations, nor does enabling them -remove the frame's refusal of direct document effects. File and Glob use the -ordinary run's contextual working-directory rules, not the Agent's empty -session directory. +permission escalation channel. An admitted XMD read is the ordinary `` or +`` on the ordinary contextual route — the installed provider, the +middleware around it and the effective `Permissions` of the consuming scope — +and it uses the ordinary run's contextual working-directory rules rather than +the Agent's empty session directory. + +The writer frame refuses no read. The packaged workflow is trusted source and +reading is how it decides what to propose, so `checkFilePath`, `readTextFile` +and `globFiles` are absent from its refusal table rather than denied and +re-permitted for one phase: a read before, during or after an information +projection, and one from a concurrent trusted workflow task, are the same +ordinary operation, and an enclosing read restriction still denies all of them +before target I/O. What the frame does refuse is every way of changing +something — write, delete, directory ensure and temporary acquisition — plus +Process, Fetch and Service, wherever they are asked from. What bounds the text +an *agent* supplies is the generated admission it runs under: a draft is +unexecuted text, and an information program enters public read-only +`` under the host's admitted vocabulary and complete preflight. ### Protection, admission, selection and documentation @@ -5421,7 +5433,7 @@ Status is measured against main. | `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-2 JSON, from one construction. `xmd syntax Elicit` names one component instead and renders its symbol metadata followed by the long-form documentation the owning package ships, through the same selection, index and renderer `` uses. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no capability and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset. The Markdown renderer is core's rather than this command's, because canonical `` prints the same symbols for a running document and the two must be the same bytes for the same site | built on the #632 stack, with the shared Markdown renderer added on this stack | | `` | outputs the components and control-flow constructs a document may write at the site it is written at, as the Markdown `xmd syntax` prints — one construction and one renderer, so an operator and an agent are never told different things about one profile. Self-closing only, with one optional closed `names` prop, and a text component: the bare form lists the symbols available here, `` renders those components' metadata and the long-form documentation their owning package ships, and the ordinary `as` captures the same text and emits nothing — while a paired spelling, an unknown prop, an empty list, a duplicate or a non-string member refuses before anything is claimed or read. It is the one member of the canonical protected tier, resolved after structural syntax and ahead of every host or author tier: a repository `Syntax.md`, a bundled `Syntax`, an ordinary or reserved registration, a host's declared Markdown and an implementation from a second loaded copy can none of them answer for it, and `Component.importComponent` middleware may observe, delegate or refuse the import without being able to answer one. What the symbols say is the execution's own — built from the selection inputs it captured before any installation, middleware or document code ran, or from the one set of symbols a trusted host stated for its profile — and they are carried lexically on canonical core's execution environment rather than through any context. The documentation the named form reads is collected the same way: each package's bootstrap contributes its own through the additive `Documentation` Api, canonical core is the terminal, and the execution collects once after the trusted host's bootstrap and snapshots by value before the root import, so middleware a running document installs composes into a chain nothing reads and two contributions that disagree about one owning package and component name refuse at collection whichever order they were bootstrapped in, while a repetition of the same four values — owner, asset, exact text and component set — adds nothing and succeeds, because one package is deliberately bootstrapped at more than one layer. Each occurrence claims the identity the execution minted, performs one `syntax_symbols` read, and retains a closed value carrying exactly one member — `{ symbols: string }` for a successful rendering, or `{ refused: non-empty string }` for a named selection canonical core refused, interpreted only after publication succeeds so it means the same thing live and on replay; a continuation hostile-parses that record and answers from it without rediscovering a moved environment, while existing `{ symbols }` histories stay readable and a missing, additional, mistyped, empty or simultaneous member is stale input that refuses before output or binding. A cancelled read completes its teardown and commits nothing. It reports itself under its own origin kind, `protected`, never as a reserved registration, and selection records `{ kind: "protected" }` alone — replay asks this execution for the implementation it built rather than resolving the name again. It decides nothing at all: a component the symbols name is neither registered, resolved nor authorized by being named | built on this stack; the narrower reference a trusted evaluation boundary installs for its subtree is the seam #713 fills | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | -| `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the Plan writer profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no ambient Files, command, service or network capability for that document; the #762 information-request boundary selects the shared ordinary profile's captured Syntax, File and Glob reads, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship reports itself as it happens: the command root's rendered transcript is progress on stderr — one Markdown phase announced before each piece of work, its attempt and repair ordinals derived from the two bounds `Plan.md` binds once, with `--verbose` adding every cleared draft, every failed check's structured findings, and the committed information requests and results described in the #762 amendment — written through a private paired `` that renders its content, sends it through the current document-output operation and returns nothing, so a phase can never enter ``'s capture or the declaration's exact-source disposition. Which surface is asking and whether `--verbose` was written are sealed host facts, so the ordinary `` surface announces nothing and expands no progress body. The host owns the stream and the terminal alike: whitespace normalization for every invocation, terminal formatting only when the entrypoint states its own stderr is one, the transcript drained inside the scope that owns the execution, and a destination that stops accepting bytes cancelling the producer and waiting for every owned teardown before it reports — with no stdout fallback. Authorship's own durable stream is the host's choice and is written rather than read: a fresh invocation-owned in-memory one, or the file `--journal` exclusively created, holding the ordinary `serializeDurableEvent()` JSONL in commit order under the same serialized pre-append secret gate, so a rejected event reaches neither the file nor the committed sequence and the prefix before it stays readable. Nothing opens either as input, replays it, or resumes from it. Then, only after every provider, Prompt task and Elicitation resource inside the Plan writer frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, `--verbose`, `--journal`, and ordinary help and version. The last two are spelled in full and observe this authorship alone — `-V` and `-j` are `xmd run`'s aliases for options about a program's run, and each is refused by naming the long spelling. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack | +| `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the Plan writer profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no write, delete, directory-ensure, temporary-directory, command, service or network capability for that document — reading is not refused at all, because the packaged workflow is trusted source and takes the ordinary contextual route under the scope's effective `Permissions`; the #762 information-request boundary selects the shared ordinary profile's Syntax, File and Glob reads, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship reports itself as it happens: the command root's rendered transcript is progress on stderr — one Markdown phase announced before each piece of work, its attempt and repair ordinals derived from the two bounds `Plan.md` binds once, with `--verbose` adding every cleared draft, every failed check's structured findings, and the committed information requests and results described in the #762 amendment — written through a private paired `` that renders its content, sends it through the current document-output operation and returns nothing, so a phase can never enter ``'s capture or the declaration's exact-source disposition. Which surface is asking and whether `--verbose` was written are sealed host facts, so the ordinary `` surface announces nothing and expands no progress body. The host owns the stream and the terminal alike: whitespace normalization for every invocation, terminal formatting only when the entrypoint states its own stderr is one, the transcript drained inside the scope that owns the execution, and a destination that stops accepting bytes cancelling the producer and waiting for every owned teardown before it reports — with no stdout fallback. Authorship's own durable stream is the host's choice and is written rather than read: a fresh invocation-owned in-memory one, or the file `--journal` exclusively created, holding the ordinary `serializeDurableEvent()` JSONL in commit order under the same serialized pre-append secret gate, so a rejected event reaches neither the file nor the committed sequence and the prefix before it stays readable. Nothing opens either as input, replays it, or resumes from it. Then, only after every provider, Prompt task and Elicitation resource inside the Plan writer frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, `--verbose`, `--journal`, and ordinary help and version. The last two are spelled in full and observe this authorship alone — `-V` and `-j` are `xmd run`'s aliases for options about a program's run, and each is refused by naming the long spelling. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack | | `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `…` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced, and neither announces a phase: the progress `xmd plan` writes is a private side effect of the command surface, so an ordinary `` expands no progress body at all. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any inspection, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's permissions are, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | | `` / `` | chooses one branch by comparing a value with `===`. `……` decides its whole case structure from source before evaluating anything, then evaluates the selector once and each non-default matcher at most once in source order, expands the first `===` match — or the final default, or nothing — inline and transparently, and appends no journal event | built on the #692 stack | | `xmd upgrade` | replaces the standalone binary that ran it with a published release, by executing one root document: the packaged upgrade command document, under the internal `` identity, with an empty component search path and no Files, Process, Service, command, Fetch, Agent, Elicitation, workflow or repository capability. That document is an **ordinary streaming text root** — it declares no `returns` and uses neither `` nor `` — so its rendered body is the command's output: each root segment reaches the reader as it completes, and a branch the command did not take contributes no prose, no phase call and no result. Its durable events go to one invocation-local in-memory stream, or to the file `--journal` named and the CLI exclusively created; neither is ever read back, and neither grants any resume or retry permission. Markdown owns the whole of the policy — the exact-tag grammar, which release is selected, semantic-version comparison through the npm `semver` package, which consent an install needs, the status, already-current and installation branches, and the wording of every refusal and every report. A compiled macOS or Linux binary whose platform the release publishes for is the only host that declares the four phases that policy may reach, ``, ``, `` and ``, and it declares them to canonical execution rather than through any contextual Api, middleware, repository lookup, ordinary `xmd run` profile or public syntax symbols; every other entrypoint states its provenance and no components at all, so an npm, Bun, Deno-source or compiled Windows invocation has no phase to reach and stops at its own refusal before release lookup or any filesystem change. That host alone owns the private half: the exact `process.execPath` spelling it will replace and never a link it resolved, one non-blocking exclusive advisory lock on a stable sidecar beside that file, the bounded anonymous GitHub reads under a scope-bound abort signal, the downloaded bytes, the digest, the staged candidate it runs for its version, and one same-directory rename. Opaque identity is the boundary between the two halves — a release identity per admitted release, then one candidate advancing `downloaded → verified → committed` exactly once, with one installation attempt per invocation — so the document chooses among the releases it was shown and can name no other release, target, asset or destination, skip verification or replay a phase. Before the rename every failure and cancellation leaves the installed file byte-identical; after it the candidate is authoritative and no cleanup restores the old bytes | built on the #659 stack | diff --git a/packages/cli/src/plan-access.ts b/packages/cli/src/plan-access.ts deleted file mode 100644 index 9b678c248..000000000 --- a/packages/cli/src/plan-access.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Which scopes inside a Plan writer frame may read the document filesystem. - * - * The writer frame refuses every capability it can reach: a Plan decides what - * to write, and writing a file, running a command, starting a service and - * reaching the network are all the approved document's business. One phase of - * the Component is the exception. A `` projection runs public - * `` under the host's own ceiling so an Agent can ask what a file - * holds before proposing a Plan — and that read now travels the ordinary Files - * route, where the frame's ambient refusal is sitting. - * - * So the refusal needs to tell one thing from another: a read performed inside - * a live information projection, and every other read in the frame. Both arrive - * at the same guard, through the same Api, with the same arguments. What - * separates them is **where** they are asked from, and that is what this - * module keeps track of. - * - * ## Why scopes, and not a context - * - * A context is reachable to anything that can spell its name, and a flag in one - * would be a permission a document could set. Private `WeakMap`s keyed by the - * real `Scope` objects are reachable to nothing but this module: a scope cannot - * be forged, cannot be named and cannot be handed over, so a holder either *is* - * the scope the grant was opened for or is a descendant of it. - * - * Descendants are the whole difficulty. The engine hosts an invocation's - * content in a scope it creates lazily from the invocation's body scope, and - * every element inside the fragment runs in further children of that. Nothing - * walks a scope's ancestry from outside, so membership is established as the - * tree is built: a Plan-local observer on `api.Scope` sees each child as it is - * created, delegates the creation unchanged, and records the child under - * whatever its parent held. That covers the hosted content, `scoped()` children - * and spawned tasks alike, because all of them create their scope through that - * one API. - * - * ## What a grant is, and is not - * - * It is not a capability. The guard it unlocks *delegates* — the enclosing and - * descendant Files middleware keep their ordinary turn, the installed provider - * still performs the operation, and the effective `Permissions` of the scope - * asking still decide it. A grant says only "this read is the information - * phase's, so do not refuse it here". - * - * It is also not a mode. Every mutation stays refused inside the projection, - * and every read stays refused outside it — a writer sibling created while a - * projection is open inherits the writer record and no grant, and a nested - * writer frame has a record of its own that another frame's grant does not - * match. Nothing here is process-wide, and nothing survives the projection: the - * grant is revoked in the projection's own `finally`, and the record with the - * writer frame it belongs to. - */ - -import { ensure, useScope } from "effection"; -import type { Operation, Scope } from "effection"; -import { api } from "effection/experimental"; - -/** One writer frame's private bookkeeping. */ -interface WriterRecord { - live: boolean; -} - -/** One live information projection, belonging to one writer frame. */ -interface ReadGrant { - live: boolean; - readonly record: WriterRecord; -} - -/** What the writer frame's capability refusal asks before it refuses a read. */ -export interface PlanWriterAccess { - /** - * Whether the scope asking is inside a live information projection of *this* - * writer frame. - * - * Read at the call, from the consuming scope, so a read performed after the - * projection settled — or in a sibling that never held a grant — is answered - * no with nothing to reset. - */ - reading(): Operation; -} - -/** The private access bookkeeping one `` declaration owns. */ -export interface PlanAccess { - /** - * Begin the writer frame, in the authenticated writer invocation's own scope. - * - * Registers its own revocation before the record exists, so a frame that is - * torn down — or interrupted while being built — leaves nothing a later read - * could match. - */ - openWriter(): Operation; - /** - * Open a read grant for this information projection, and answer with how to - * close it. - * - * Called in the authenticated `` body, before its first - * projection, so the scope it binds is the one the engine hosts that - * projection's content from. A wrapper scope opened here instead would be a - * sibling of the hosted content rather than its parent, and the content would - * inherit nothing. - */ - openInformation(): Operation<() => void>; -} - -/** - * One declaration's access bookkeeping. - * - * Built where the `` declaration is built, and its closures handed to the - * two private factories — so the maps are shared by exactly the phases of one - * Component and reachable from nothing else. Not a package export, not a - * context, and not a general facility: an implementation detail of this - * Component with the lifetime of the declaration that holds it. - */ -export function createPlanAccess(): PlanAccess { - const records = new WeakMap(); - const grants = new WeakMap(); - - /** - * Follow the tree as it grows. - * - * The delegation is unconditional and the result is returned exactly as it - * came back: this observes scope creation and changes nothing about it. A - * child is recorded under its parent's record, and under its parent's grant - * when the parent is inside a live projection — which is how the hosted - * content of an information body, and everything that content creates, come - * to be covered without anything walking ancestry later. - */ - function observe(scope: Scope): void { - scope.around(api.Scope, { - create([parent], next) { - const created = next(parent); - const child = created[0]; - const record = records.get(parent); - if (record !== undefined) { - records.set(child, record); - } - const grant = grants.get(parent); - if (grant !== undefined) { - grants.set(child, grant); - } - return created; - }, - }); - } - - return { - *openWriter(): Operation { - const scope = yield* useScope(); - const record: WriterRecord = { live: true }; - // Registered before the record is reachable, so an interruption between - // the two leaves no record behind rather than one nothing revokes. - yield* ensure(() => { - record.live = false; - }); - records.set(scope, record); - observe(scope); - return { - *reading(): Operation { - const asking = yield* useScope(); - const grant = grants.get(asking); - // All three, and the record identity is the one that is easy to - // forget: a nested writer frame's projection holds a live grant, and - // it is not this frame's to honour. - return grant !== undefined && grant.live && grant.record.live && grant.record === record; - }, - }; - }, - *openInformation(): Operation<() => void> { - // The body's own scope, which is the parent the engine creates this - // invocation's hosted content from. - const scope = yield* useScope(); - const record = records.get(scope); - if (record === undefined) { - // An information phase outside any writer frame of this declaration. - // Nothing to grant and nothing to refuse differently: the projection - // runs under whatever the enclosing scope already permits. - return () => {}; - } - const grant: ReadGrant = { live: true, record }; - grants.set(scope, grant); - return () => { - grant.live = false; - }; - }, - }; -} diff --git a/packages/cli/src/plan-component.ts b/packages/cli/src/plan-component.ts index 6652f1eb2..8aaee7a7d 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -75,8 +75,6 @@ import type { import type { DocumentValidation } from "@executablemd/core"; import type { ComponentInvocation } from "@executablemd/core"; -import { createPlanAccess } from "./plan-access.ts"; -import type { PlanAccess } from "./plan-access.ts"; import { DEFAULT_PLAN_WRITER_ROOT, installPlanWriterFrame, @@ -402,9 +400,6 @@ export function* planComponentDeclaration( const validate = assembly.validate ?? structuralValidation(assembly.includes, declared, assembly.plugins ?? NO_PLUGINS); - // The private access bookkeeping these phases share, built here so the two - // factories below close over one set of maps and nothing else can reach them. - const access = createPlanAccess(); const declaration = Markdown({ name: PLAN_COMPONENT, origin: PLAN_ORIGIN, @@ -420,12 +415,12 @@ export function* planComponentDeclaration( exact: true, privates: [ planInputs(assembly), - planWriter(assembly, access), + planWriter(assembly), planProgress(assembly), checkDraft(validate), admitPlan(validate), classifyPlanResponseComponent(), - planInformation(access), + planInformation(), ], }); declared.push(declaration); @@ -634,7 +629,7 @@ function placementFor( * Component — the structural admission and the return — is therefore written after * teardown by construction rather than by a rule somebody has to remember. */ -function planWriter(assembly: PlanComponentAssembly, access: PlanAccess): IdentityComponent { +function planWriter(assembly: PlanComponentAssembly): IdentityComponent { return { name: "PlanWriter", origin: `${PLAN_ORIGIN}#PlanWriter`, @@ -646,12 +641,11 @@ function planWriter(assembly: PlanComponentAssembly, access: PlanAccess): Identi invocation: ComponentInvocation, ): Operation { // The identity canonical execution minted where resolution selected - // this exact implementation, for this exact expansion. Asked before the - // frame's access bookkeeping is opened, because what opens it has to be - // this invocation rather than a name, a copied function or a caller's - // claim that it is one. + // this exact implementation, for this exact expansion. What it protects + // is the private invocation and the projection beneath it — a name, a + // copied function or a caller's claim that it is one cannot stand here — + // and it decides nothing about permission. yield* claim(invocation); - const writerAccess = yield* access.openWriter(); // Before a directory exists, before a provider exists, and therefore // before any session could be placed or any turn started. A host that // supplies no Agent context refuses rather than writing a Plan under @@ -681,7 +675,6 @@ function planWriter(assembly: PlanComponentAssembly, access: PlanAccess): Identi planWriter: context.value, host: assembly.host, session, - access: writerAccess, ...(typeof props.authoredSession === "string" ? { authoredSession: props.authoredSession } : {}), @@ -804,22 +797,6 @@ function classifyPlanResponseComponent(): IdentityComponent { }; } -/** - * Run `body`, and close the read grant however it settles. - * - * The grant has to outlive the projection *and* its joined teardown, and it has - * to be gone before the settled text is classified or scanned — which is - * exactly a `finally` around the one call, written here so the phase below - * reads as the sequence it is. - */ -function* holding(close: () => void, body: () => Operation): Operation { - try { - return yield* body(); - } finally { - close(); - } -} - /** * Run one information request, and say how it settled. * @@ -855,7 +832,7 @@ function* holding(close: () => void, body: () => Operation): Operation * disclosed. Canonical execution owns the policy and the scanner; this asks * them and appends nothing. */ -function planInformation(access: PlanAccess): IdentityComponent { +function planInformation(): IdentityComponent { return { name: "PlanInformation", origin: `${PLAN_ORIGIN}#PlanInformation`, @@ -867,18 +844,16 @@ function planInformation(access: PlanAccess): IdentityComponent { _props: Record, invocation: ComponentInvocation, ): Operation { - // Authenticated before anything is granted, for the reason the writer - // phase is: a read grant anything shaped like this could open would be - // a read the writer frame does not get to refuse. + // Authenticated, for the reason the writer phase is: this phase runs + // the agent's text under the host's read-only generated selection, and + // an invocation canonical execution did not issue is not that phase. + // It authorizes no filesystem access of its own — the trusted workflow + // reads through the ordinary chain either way. yield* claim(invocation); - // Bound to this body's own scope, which is the parent the engine hosts - // the projection's content from — held open across the projection and - // its joined teardown, and revoked here however it settled. - const close = yield* access.openInformation(); // The child and its structured teardown have both settled by the time // this returns, so what is scanned below is complete text and not a // fragment still able to produce more. - const projected = yield* holding(close, () => tryContent()); + const projected = yield* tryContent(); const settled = ((): { status: string; text: string } => { if (projected.failure === undefined) { return { status: "found", text: projected.text }; diff --git a/packages/cli/src/plan-writer-profile.ts b/packages/cli/src/plan-writer-profile.ts index 625b0b199..334411b12 100644 --- a/packages/cli/src/plan-writer-profile.ts +++ b/packages/cli/src/plan-writer-profile.ts @@ -54,7 +54,6 @@ import { API } from "@executablemd/runtime"; import { FormOpener } from "@executablemd/web"; import { hostAcpDependencies } from "./agent-stack.ts"; -import type { PlanWriterAccess } from "./plan-access.ts"; import type { PlanWriterStack } from "./agent-stack.ts"; import { ordinaryEvaluationProfile } from "./evaluation-profile.ts"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; @@ -331,15 +330,6 @@ export interface PlanWriterFrame { readonly session: string; /** The exact authored label a trusted child host may address privately. */ readonly authoredSession?: string; - /** - * What this frame's capability refusal asks before it refuses a read. - * - * The authenticated information phase of the Component holds a grant for the - * scope its projection runs in, and this is how the ambient refusal below - * recognizes one. A frame installed without it refuses every read, which is - * what a surface with no information phase wants. - */ - readonly access?: PlanWriterAccess; observe?(reference: PlanWriterObservation): Operation; installElicitation(): Operation; } @@ -380,7 +370,7 @@ export function* installPlanWriterFrame(frame: PlanWriterFrame): Operation policy: PLAN_WRITER_POLICY, }); yield* installPlanPromptFailurePolicy(); - yield* refuseDocumentCapabilities(frame.access); + yield* refuseDocumentCapabilities(); // After everything, and it is the installation rather than an account of one. // Whether the prompt-failure policy is installed is not reported here at all: // a value saying so would agree with itself however the middleware behaved, @@ -788,43 +778,30 @@ function* releaseSessionDirectory(directory: string, claim: DirectoryClaim): Ope * running a command, starting a service and reaching the network are all the * approved document's business, under the caller's own configuration. * - * ## The one exception, and how it is recognized - * - * An authenticated `` projection runs public `` - * under the host's own ceiling, so an Agent can ask what a file holds before - * proposing a Plan. That read performs the ordinary `` or `` - * component, so it arrives here like any other — and `access.reading()` is - * what tells the two apart: it answers for the scope the call was made from, - * and only a scope inside a live projection of *this* writer frame is granted. - * - * What the exception does is **delegate**, through this middleware's own - * `next`. The enclosing and descendant Files middleware keep their ordinary - * turn, the installed provider still performs the operation, and the effective - * `Permissions` of the consuming scope still decide it. Nothing here answers a - * permission with true, and nothing runs the request in a wider scope. - * - * Reads only. Every mutation stays refused wherever it is asked from — a - * projection that wrote a file would be an unaudited fragment changing the - * caller's filesystem under a frame whose whole purpose is that it cannot — - * and so does every read outside a projection. + * ## Reading is not one of them + * + * The packaged workflow is trusted source, and reading is how it decides what + * to propose: it inspects the project before drafting, and the agent's own + * information request is public read-only `` under the host's + * admitted vocabulary and complete preflight. So reads are absent from this + * table rather than refused and re-permitted somewhere — `checkFilePath`, + * `readTextFile` and `globFiles` are simply not members of it, and they take + * the ordinary chain: the provider the entrypoint or the enclosing host + * installed, the middleware around it, and the effective `Permissions` of the + * scope asking. An enclosing read restriction therefore still denies them, and + * this frame answers `Permissions` with nothing. + * + * What stays refused is every way of *changing* anything, wherever it is asked + * from. An agent's text is not trusted source; what bounds it is the generated + * admission it runs under rather than a read denial here. */ -function* refuseDocumentCapabilities(access: PlanWriterAccess | undefined): Operation { +function* refuseDocumentCapabilities(): Operation { const refuse = (capability: string) => () => { throw new Error( `xmd plan asked for ${capability}, which the Plan writer profile grants to nothing`, ); }; - /** Whether this call is the information phase's own read. */ - function* granted(): Operation { - return access === undefined ? false : yield* access.reading(); - } yield* API.Files.around({ - *checkFilePath([input], next) { - return (yield* granted()) ? yield* next(input) : refuse("a file")(); - }, - *readTextFile([input], next) { - return (yield* granted()) ? yield* next(input) : refuse("a file")(); - }, // deno-lint-ignore require-yield *writeTextFile() { return refuse("a file")(); @@ -847,9 +824,6 @@ function* refuseDocumentCapabilities(access: PlanWriterAccess | undefined): Oper *ensureDirectory() { return refuse("a directory")(); }, - *globFiles([input], next) { - return (yield* granted()) ? yield* next(input) : refuse("a file")(); - }, // deno-lint-ignore require-yield *temporaryDirectory() { return refuse("a directory")(); diff --git a/packages/cli/tests/plan-access.test.ts b/packages/cli/tests/plan-access.test.ts deleted file mode 100644 index 9a47f27b9..000000000 --- a/packages/cli/tests/plan-access.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Tier PLA — which scopes a Plan writer frame grants a read to. - * - * The integration rows live in the Plan suites: a request crossing the shared - * Files chain, a restriction denying it, the writer frame refusing everything - * ambient. What those cannot show cheaply is the *shape* of the grant — a - * sibling created while a projection is open, a nested frame's grant offered to - * an outer frame's guard, a grant asked about after it closed. Each of those is - * one scope relationship, and this drives them directly. - * - * Driving the mechanism rather than a document is the point. The guard asks one - * question — "is the scope asking inside a live projection of my frame?" — and - * every way of answering it wrongly is a way of handing an unaudited fragment, - * or the writer frame itself, a read it was not given. - */ - -import { describe, it } from "@executablemd/test-support/bdd"; -import { expect } from "@executablemd/test-support/expect"; -import { scoped, spawn, withResolvers } from "effection"; - -import { createPlanAccess } from "../src/plan-access.ts"; -import type { PlanWriterAccess } from "../src/plan-access.ts"; - -describe("Tier PLA — the read grant inside a Plan writer frame", () => { - it("PLA1: a writer frame grants nothing until a projection opens", function* () { - const access = createPlanAccess(); - yield* scoped(function* () { - const writer = yield* access.openWriter(); - - // The frame's own scope, and a child of it: neither is a projection. - expect(yield* writer.reading()).toBe(false); - yield* scoped(function* () { - expect(yield* writer.reading()).toBe(false); - }); - }); - }); - - it("PLA2: a projection grants its own scope and everything created under it", function* () { - const access = createPlanAccess(); - yield* scoped(function* () { - const writer = yield* access.openWriter(); - - const close = yield* scoped(function* () { - const held = yield* access.openInformation(); - expect(yield* writer.reading()).toBe(true); - // A child created after the grant opened — which is what the engine's - // hosted content, and everything the fragment runs, actually are. - yield* scoped(function* () { - expect(yield* writer.reading()).toBe(true); - // And a child of that child, because a fragment nests. - yield* scoped(function* () { - expect(yield* writer.reading()).toBe(true); - }); - }); - return held; - }); - - // Revoked, and the answer is no from the frame again. The projection's - // own scope has ended too, so this is the frame asking. - close(); - expect(yield* writer.reading()).toBe(false); - }); - }); - - it("PLA3: a sibling created while a projection is open is granted nothing", function* () { - const access = createPlanAccess(); - const opened = withResolvers(); - const observed: boolean[] = []; - - yield* scoped(function* () { - const writer = yield* access.openWriter(); - - // The concurrent writer sibling: a task of the frame's own, running - // while the projection below is suspended mid-read. - const sibling = yield* spawn(function* () { - yield* opened.operation; - observed.push(yield* writer.reading()); - }); - - yield* scoped(function* () { - yield* access.openInformation(); - expect(yield* writer.reading()).toBe(true); - // Opened, and held open: the sibling asks while this is live rather - // than after it settled, which is the only ordering that could be - // mistaken for a grant. - opened.resolve(); - yield* sibling; - }); - }); - - // The sibling inherited the writer record — it is in the frame — and no - // read grant, because its parent held none when it was created. - expect(observed).toEqual([false]); - }); - - it("PLA4: one frame's projection does not satisfy another frame's guard", function* () { - const access = createPlanAccess(); - yield* scoped(function* () { - const outer = yield* access.openWriter(); - - yield* scoped(function* () { - // A second writer frame, nested inside the first. Its projection is - // perfectly good — for it. - const inner = yield* access.openWriter(); - yield* scoped(function* () { - yield* access.openInformation(); - expect(yield* inner.reading()).toBe(true); - // The outer frame's guard is asked about the same scope and answers - // no: a grant belongs to the frame that opened it, and a nested - // conversation cannot read on an enclosing one's behalf. - expect(yield* outer.reading()).toBe(false); - }); - }); - }); - }); - - it("PLA5: a retained guard answers no once its frame has ended", function* () { - const access = createPlanAccess(); - let retained: PlanWriterAccess | undefined; - let granted: boolean | undefined; - - yield* scoped(function* () { - yield* scoped(function* () { - retained = yield* access.openWriter(); - const close = yield* access.openInformation(); - expect(yield* retained.reading()).toBe(true); - close(); - }); - // The frame is gone. Whatever held its guard is holding something that - // answers for a conversation that has ended. - granted = retained === undefined ? undefined : yield* retained.reading(); - }); - - expect(granted).toBe(false); - }); - - it("PLA6: an information phase outside any frame of this declaration grants nothing", function* () { - const access = createPlanAccess(); - yield* scoped(function* () { - // No writer frame was opened, so there is no record to bind a grant to. - // The projection runs under whatever the enclosing scope permits, which - // is the honest answer: there is no frame here to refuse it. - const close = yield* access.openInformation(); - close(); - // Nothing to assert about a guard, because there is no guard. What this - // row establishes is that opening the phase is not itself a grant: a - // frame opened afterwards sees no projection. - const writer = yield* access.openWriter(); - expect(yield* writer.reading()).toBe(false); - }); - }); -}); diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index aba7998bb..fd6c64b8c 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -32,10 +32,6 @@ import { } from "@executablemd/core"; import type { Json, SyntaxSymbols } from "@executablemd/core"; import { content, registerComponents, validateDocument } from "@executablemd/core"; -// Core's own seam for substituting a scanner, which is how a case reaches the -// moment after an information projection settled and before its findings are -// disclosed. Deliberately not a package export: tests are its only consumer. -import { useSecretScannerFactory } from "../../core/src/secrets/policy.ts"; import { executeInstalled, fileReadEntry, @@ -658,18 +654,10 @@ describe("Tier PC — in an ordinary document", () => { const READ_REQUEST = '\n\n'; /** A harness answering a file read first and a Plan second. */ - function* reading_( - root: string, - hooks: { - readonly insideFrame?: () => Operation; - readonly atReview?: () => Operation; - } = {}, - ): Operation { + function* reading_(root: string): Operation { const harness = yield* planDeclarationHarness({ surface: "component", planWriterRoot: root, - ...(hooks.insideFrame === undefined ? {} : { insideFrame: hooks.insideFrame }), - ...(hooks.atReview === undefined ? {} : { atReview: hooks.atReview }), }); harness.fake.script({ reply: READ_REQUEST }); harness.fake.script({ reply: PLAN }); @@ -677,101 +665,22 @@ describe("Tier PC — in an ordinary document", () => { return harness; } - /** What the writer frame refuses a read with. */ - const FRAME_REFUSAL = - "xmd plan asked for a file, which the Plan writer profile grants to nothing"; - - /** What an ambient read inside the frame answered with. */ - function* ambient(): Operation { - try { - yield* API.Files.operations.readTextFile({ cwd: ".", path: "notes.md" }); - return "granted"; - } catch (error) { - return error instanceof Error ? error.message : String(error); - } - } - /** - * The frame refuses its own reads before, during and after the one it - * delegates. - * - * The grant belongs to the scope the projection runs in, so the discriminating - * case is a *concurrent* one: an ordinary sibling task of the frame, running - * while the information read is held open inside the provider. It asks - * through the real guard — the frame's own installed Files middleware — and - * must reach no provider at all. + * The private phase runs for the invocation the engine issued, and no other. * - * The handshake is what makes it an ordering claim rather than a timing - * one: the provider resolves `entered` from inside the read, the sibling - * asks and then releases it, and the read returns afterwards. - */ - it("PI24: a concurrent writer sibling is refused while the information read is held", function* () { - yield* useWorkingDirectory(function* () { - const entered = withResolvers(); - const released = withResolvers(); - const asked: string[] = []; - const files = recordedFiles( - { "notes.md": "the retained note\n" }, - { - *hold(path) { - if (path === "notes.md") { - entered.resolve(); - yield* released.operation; - } - }, - }, - ); - - const run = yield* runDocument({ - source: SOURCE, - harness: yield* reading_(yield* planWriterRoot(), { - *insideFrame(): Operation { - // Before any turn, in the frame itself: the frame reads nothing. - asked.push(`before: ${yield* ambient()}`); - yield* spawn(function* () { - yield* entered.operation; - asked.push(`during: ${yield* ambient()}`); - released.resolve(); - }); - }, - *atReview(): Operation { - // Strictly after the information phase settled: the review is of - // the draft the *next* turn produced. - asked.push(`after: ${yield* ambient()}`); - }, - }), - reviews: [], - evaluation: reading(), - files: files, - }); - - expect(run.failure).toBe(undefined); - expect(run.output).toContain(`got: ${PLAN}`); - // The delegated read happened, exactly once, and it is the only - // operation the provider ever saw. - expect(files.performed).toEqual(["read notes.md"]); - // And all three ambient attempts were refused by the frame's own guard. - expect(asked).toEqual([ - `before: ${FRAME_REFUSAL}`, - `during: ${FRAME_REFUSAL}`, - `after: ${FRAME_REFUSAL}`, - ]); - }); - }); - - /** - * And access does not open for something merely shaped like the phase. + * `claim(invocation)` is canonical execution's own authentication, and it is + * the first thing this phase does: an invocation it did not issue is refused + * before the projection exists. What that protects is the private phase and + * the program it projects — it authorizes no filesystem access, because the + * trusted workflow reads through the ordinary chain either way. * - * The grant is opened after `claim(invocation)`, which is canonical - * execution's own authentication: an invocation it did not issue is refused - * there, before any bookkeeping exists. This drives the **real private - * body** off the declaration the production path builds, with a claimant - * that refuses the way core's does for a forged invocation — and the - * control beside it, a claimant that answers, shows the body gets past the - * claim and on to its projection. So the ordering is what is asserted - * rather than the fact that something threw. + * This drives the **real private body** off the declaration the production + * path builds, with a claimant that refuses the way core's does for a forged + * invocation; the control beside it, a claimant that answers, shows the body + * gets past the claim and on to its projection. So the ordering is what is + * asserted rather than the fact that something threw. */ - it("PI25: the private information body opens no access without its claimant", function* () { + it("PI25: the private information body runs only for its own invocation", function* () { yield* useWorkingDirectory(function* () { const harness = yield* planDeclarationHarness({ surface: "component", @@ -1006,64 +915,6 @@ describe("Tier PC — in an ordinary document", () => { }); }); - /** - * The grant ends with the projection, observed from the scope that held it. - * - * This is the one assertion the rows above cannot make. A cancelled run and - * a refused writer sibling both leave the revocation untested: the sibling - * never held the grant, and a halted run takes the whole frame with it — so - * removing the `finally` that closes the grant keeps every other case here - * green. The Planner demonstrated exactly that, and this is their probe - * kept as a regression. - * - * What makes it possible is where the secret scan runs: inside the - * information body, after `tryContent()` and its joined teardown have - * settled and after the grant was closed, and still in that body's own - * scope. A read attempted there is a read from the scope that *was* - * granted, at a moment when it must not be any more. The scanner factory is - * core's own test seam for substituting a scanner, which is what makes that - * moment reachable without a product hook. - */ - it("PI29: the read grant has ended by the time the findings are scanned", function* () { - yield* useWorkingDirectory(function* () { - const observed: string[] = []; - const files = recordedFiles({ "notes.md": "the retained note\n" }); - - const run = yield* scoped(function* () { - yield* useSecretScannerFactory(() => ({ - *scan(text: string) { - // The findings, and not the draft: only they carry the note. - if (text.includes("the retained note")) { - observed.push(yield* ambient()); - } - return []; - }, - })); - return yield* runDocument({ - source: SOURCE, - harness: yield* reading_(yield* planWriterRoot()), - reviews: [], - evaluation: reading(), - files: files, - }); - }); - - expect(run.failure).toBe(undefined); - expect(run.output).toContain(`got: ${PLAN}`); - // The scan happened — this is not a case where nothing ran — and the - // read it attempted from the still-live body scope was refused. - // The findings cross the scanner more than once — the phase's own gate - // and the disclosure that follows it — so what is asserted is that the - // scan happened at all and that every attempt from that scope was - // refused, rather than a count this row has no business fixing. - expect(observed.length).toBeGreaterThan(0); - expect([...new Set(observed)]).toEqual([FRAME_REFUSAL]); - // One read, the projection's own: the probe's attempt reached no - // provider. - expect(files.performed).toEqual(["read notes.md"]); - }); - }); - it("PI12: an embedded request emits no command progress into the document", function* () { yield* useWorkingDirectory(function* () { const files = recordedFiles({ "notes.md": "the retained note\n" }); diff --git a/packages/cli/tests/plan.test.ts b/packages/cli/tests/plan.test.ts index a8dd5e5cd..b65da7c6d 100644 --- a/packages/cli/tests/plan.test.ts +++ b/packages/cli/tests/plan.test.ts @@ -731,7 +731,12 @@ describe( const harness = createPlanHarness({ planWriterRoot }); harness.fake.script({ reply: VALID }); - const seen: { cwd?: string; entries?: string[]; refusals: string[] } = { refusals: [] }; + const seen: { + cwd?: string; + entries?: string[]; + refusals: string[]; + read?: string; + } = { refusals: [] }; harness.deps.installElicitation = function* () { yield* Elicitation.around( { @@ -740,19 +745,24 @@ describe( const cwd = harness.fake.created[0]?.cwd; seen.cwd = cwd; seen.entries = cwd === undefined ? undefined : yield* until(readdir(cwd)); - // The command document's own capabilities, asked for from inside - // its scope. It decides what to write; it writes nothing. + // The trusted workflow reading the caller's tree, from inside + // the writer frame and outside any information projection. It + // is the ordinary contextual operation: the provider this + // command's entrypoint installed, the middleware around it, and + // the effective Permissions of this scope. The frame adds no + // read denial of its own, and asserting the *bytes* is what + // makes that a read rather than an absent refusal — a no-throw + // or a missing-file `Err` would pass against a provider that + // reached nothing. + const read = yield* API.Files.operations.readTextFile({ + cwd: dir, + path: "secret.txt", + }); + seen.read = read.ok ? read.value : `refused: ${String(read.error)}`; + // And the capabilities it does not get, asked for from the same + // scope. It decides what to write; it writes nothing. for (const [name, ask] of [ ["a directory", () => API.Files.operations.temporaryDirectory()], - // Ambient, and therefore refused: a read is delegated only - // inside an authenticated information projection, and this is - // the writer frame asking for itself. Asked here because a - // read is the one operation the frame now has a reason to - // delegate at all. - [ - "a file", - () => API.Files.operations.readTextFile({ cwd: ".", path: "notes.md" }), - ], // The semantic directory operation, named in the table rather // than left unreachable: canonical core owns `` now, and // an admitted generated one performs this. @@ -789,10 +799,13 @@ describe( // allows no native tool on a fresh session. expect(harness.fake.created[0]?.mcpServers).toEqual([]); expect(harness.fake.ensured[0]?.sessionOptions?.allowedTools).toEqual([]); - // And the command document is given nothing to act with. + // The trusted workflow read the caller's file, through the ordinary + // chain, and got its bytes. + expect(seen.read).toBe("the caller's tree\n"); + // And it is given nothing to *change* with, nor a command, nor the + // network. The read above does not reinterpret any of these. expect(seen.refusals).toEqual([ "xmd plan asked for a directory, which the Plan writer profile grants to nothing", - "xmd plan asked for a file, which the Plan writer profile grants to nothing", "xmd plan asked for a directory, which the Plan writer profile grants to nothing", "xmd plan asked for a command, which the Plan writer profile grants to nothing", "xmd plan asked for the network, which the Plan writer profile grants to nothing", diff --git a/packages/cli/tests/support/plan-harness.ts b/packages/cli/tests/support/plan-harness.ts index 8703d6b9e..943e5320c 100644 --- a/packages/cli/tests/support/plan-harness.ts +++ b/packages/cli/tests/support/plan-harness.ts @@ -330,25 +330,6 @@ export function* planDeclarationHarness(options: { /** Absent leaves the harness with no stack at all, as `xmd test` has none. */ stack?: PlanWriterStack | null; store?: FakeStore; - /** - * Work this case runs *inside* the Plan writer frame, before any turn. - * - * The one place a case can stand where the frame's own refusals apply to it: - * the frame's own observation point, reached after the capability refusals - * are installed and before any turn. A task spawned here is an ordinary - * sibling of the Component's phases and reaches the real guard rather than a - * model of it, which is how a concurrent writer read is attempted while an - * information projection is open. - */ - insideFrame?: () => Operation; - /** - * Work this case runs inside the frame when a review is asked. - * - * Strictly after the information phase settled, because the review is of the - * draft a later turn produced — which is what makes it the "afterwards" - * position for an ambient read. - */ - atReview?: () => Operation; }): Operation { const fake = createFakeAcp(); const checked: string[] = []; @@ -376,39 +357,16 @@ export function* planDeclarationHarness(options: { }, ), planWriterRoot: options.planWriterRoot, - // Where the frame reports itself, which is *after* its capability refusals - // are installed and before any turn — so work a case runs here is work the - // frame's own guard answers for. - ...(options.insideFrame === undefined - ? {} - : { - *observePlanWriter(): Operation { - yield* options.insideFrame!(); - }, - }), ...(options.session === undefined ? {} : { session: options.session }), ...(options.explicitSession === undefined ? {} : { explicitSession: options.explicitSession }), ...(options.verbose === undefined ? {} : { verbose: options.verbose }), host: yield* useScope(), - // Where the frame reports itself, which is *after* its capability refusals - // are installed and before any turn — so work a case runs here is work the - // frame's own guard answers for. - ...(options.insideFrame === undefined - ? {} - : { - *observePlanWriter(): Operation { - yield* options.insideFrame!(); - }, - }), - *installElicitation() { yield* Elicitation.around( { + // deno-lint-ignore require-yield *elicit([request], _next) { reviews.push(request); - if (options.atReview !== undefined) { - yield* options.atReview(); - } const answer = answers.shift(); if (answer === undefined) { throw new Error("the case scripted no answer for this review"); diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 01a46751a..a1af74e09 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -505,8 +505,24 @@ provider: - an empty requested native-tool allowlist on a fresh session; - strict denial of every native permission request, answered inside the provider without consulting an authored approval scope; and -- no Files, command, service or XMD-mediated network capability for the command - document itself. +- no write, delete, directory-ensure, temporary-directory, command, service or + XMD-mediated network capability for the command document itself. + +**Reading is not one of the refusals.** The packaged workflow is trusted source +and reading is how it decides what to propose, so `checkFilePath`, +`readTextFile` and `globFiles` are absent from that table rather than refused: +they take the ordinary route — the provider the entrypoint or the enclosing host +installed, the middleware around it, and the effective `Permissions` of the +scope asking. A read before, during or after an information projection, and one +made by a concurrent trusted workflow task, are all the same ordinary +operation. An enclosing read restriction therefore still denies them before +target I/O, and the frame answers `Permissions` with nothing. + +What that boundary does *not* relax is anything an agent supplies. Agent text is +either an unexecuted draft or an information program that enters public +read-only ``, under the host's admitted vocabulary and complete +preflight; the frame's mutation, Process, Fetch and Service refusals and the +native-tool denial above are unchanged. The provider may use its own transport to perform the model turn; that does not grant the Agent a native network tool. Nor do the two acts the command performs @@ -700,30 +716,15 @@ construct. `` selects only `read`. It installs no profile of its own, creates no second evaluator and starts no child execution; a trusted host that already stated a profile keeps it. -*The request travels the ordinary route, and the writer frame is the reason that -takes saying.* The frame a Plan is written under refuses every capability it can -reach, and an admitted read is now the ordinary `` or `` performing -its work through contextual `API.Files` — so it arrives at that same refusal. -The one exception is this phase of the Component, recognized by **where** the -call was made from rather than by what it asked for: the authenticated -information phase holds a private read grant for the scope its projection runs -in, and the frame's guard delegates `checkFilePath`, `readTextFile` and -`globFiles` for that scope alone, through its own `next`. - -Delegation is all it does. Every enclosing and descendant Files middleware keeps -its ordinary turn, the installed provider still performs the operation, and the -effective `Permissions` of the consuming scope still decide it — so a read -restriction in force around the document denies the request, and the denial -becomes one safe retry context like any other ordinary refusal. The grant -answers Permissions with nothing and runs the request in no wider scope. - -Reads only, and that phase only. Every mutation stays refused wherever it is -asked from — write, delete, directory ensure and temporary acquisition — and so -does every read outside a live projection: before one opens, after it settles, -in a concurrent sibling of the frame that never held a grant, and in a nested -writer frame whose own grant belongs to its own conversation. Process, Fetch and -Service refusal and the native Agent policy are unchanged across the whole -frame. +*The request travels the ordinary route.* An admitted read is the ordinary +`` or `` performing its work through contextual `API.Files`, so +every enclosing and descendant handler keeps its ordinary turn, the installed +provider performs the operation, and the effective `Permissions` of the +consuming scope decide it — a read restriction in force around the document +denies the request, and the denial becomes one safe retry context like any other +ordinary refusal. The writer frame adds nothing to that route and takes nothing +away from it: it refuses mutations, Process, Fetch and Service wherever they are +asked from, and reads are not among them. *The budget is eight requests for one invocation*, shared across initial drafting, repair and revision. A success and a safe refusal each spend one. They @@ -1145,12 +1146,9 @@ neither observation never interpreted what it wrote. | PI11 | Language, not permission | Branching, binding and bounded iteration compose with admitted reads under ordinary rules, and a prohibited component in an untaken branch refuses the whole fragment with zero reads | | PI20 | The shared route, from both sides | On both surfaces: one middleware the document installed observes the request the fragment made, a second installed nearer the provider transforms it, and the findings the next turn receives carry the transformed answer — so an information request is the ordinary operation and not a private one | | PI21 | Current policy decides it | A read restriction the enclosing scope installed denies the request before the provider performs anything; the refusal becomes exactly one more turn carrying the sanitized sentence, and authorship still finishes. The ordering half — a descendant answering `true` cannot defeat an enclosing `false` — is Tier PM's | -| PI22 | The writer frame still refuses everything else | Inside the frame and outside any projection, a read, a temporary directory, a semantic directory ensure, a command and the network are each refused with the frame's own fixed sentence | -| PI24 | Isolation around a live projection | With an information read held open inside the provider, a concurrent sibling task of the writer frame asks through the real guard and is refused, reaching no provider; the same frame is refused before any turn and again at review time, strictly after the information phase settled; the delegated read is the only operation the provider ever saw | -| PI25 | Authenticity at the private body | The real private information body, built from the production declaration, refuses an invocation its claimant does not authenticate and opens no access; with a claimant that answers, the same body gets past the claim and fails later at its projection | +| PI22 | Trusted reads, and the refusals that remain | From inside the writer frame and outside any information projection, a read of the caller's own file reaches the installed ordinary chain and returns its exact bytes — a no-throw or a missing-file `Err` would not discriminate it. A temporary directory, a semantic directory ensure, a command and the network are each still refused with the frame's own fixed sentence, and the native-tool denial, empty MCP set and session directory claims are unchanged | +| PI25 | Authenticity at the private body | The real private information body, built from the production declaration, refuses an invocation its claimant does not authenticate, so the phase and the program it projects run only for the invocation the engine issued; with a claimant that answers, the same body gets past the claim and fails later at its projection. It authorizes no filesystem access either way | | PI26 | A provider that raises | A throwing Files provider ends authorship with one prompt and no retry turn, and no Plan | | PI27 | Cancelling an entered read | A halt landing inside the provider runs the cleanup registered with the read and does not complete while it is held — read while it is held, not afterwards — and the cancelled read is the only operation performed | | PI28 | The region, and leaving it | One document with two `` elements: the first inside a read-only region has its request denied and gets one more turn; the second, outside it, performs the request through the restored enclosing allowance | -| PI29 | The grant ends with the projection | A read attempted from the information body's *still-live* scope, at the moment its settled findings are scanned — after the projection and its joined teardown finished — is refused by the frame. It is the one case a cancelled run and a refused writer sibling cannot make: neither distinguishes a grant that is never closed | -| PI23 | The shape of the grant | A frame grants nothing until a projection opens; a projection grants its own scope and every scope created under it; a sibling created while one is open is granted nothing; one frame's projection does not satisfy another frame's guard; and a retained guard answers no once its frame has ended | | PI12 | Disclosure order | Observe default, verbose, journal and Agent prompts for success and refusal, then place a synthetic secret in a file read by an information request. Default output stays content-free; detailed findings follow settlement; the secret appears in no output, journal entry or Agent Prompt, starts no following turn, review or artifact, and ends with the existing terminal secret rejection |