diff --git a/architecture.md b/architecture.md index e3731dc7d..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 @@ -2183,24 +2195,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 +4639,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 @@ -5388,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 | @@ -5460,7 +5505,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-component.ts b/packages/cli/src/plan-component.ts index 8cc93f395..8aaee7a7d 100644 --- a/packages/cli/src/plan-component.ts +++ b/packages/cli/src/plan-component.ts @@ -635,8 +635,17 @@ function planWriter(assembly: PlanComponentAssembly): IdentityComponent { 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. 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); // 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 @@ -830,8 +839,17 @@ function planInformation(): IdentityComponent { forms: ["paired"], props: EMPTY_PROPS, returns: INFORMATION_RETURNS, - factory: () => - function* PlanInformation(): Operation { + factory: (claim: IdentityClaimant) => + function* PlanInformation( + _props: Record, + invocation: ComponentInvocation, + ): Operation { + // 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); // 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. diff --git a/packages/cli/src/plan-writer-profile.ts b/packages/cli/src/plan-writer-profile.ts index 69790579e..334411b12 100644 --- a/packages/cli/src/plan-writer-profile.ts +++ b/packages/cli/src/plan-writer-profile.ts @@ -777,6 +777,23 @@ 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. + * + * ## 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(): Operation { const refuse = (capability: string) => () => { @@ -785,14 +802,6 @@ function* refuseDocumentCapabilities(): Operation { ); }; yield* API.Files.around({ - // deno-lint-ignore require-yield - *checkFilePath() { - return refuse("a file")(); - }, - // deno-lint-ignore require-yield - *readTextFile() { - return refuse("a file")(); - }, // deno-lint-ignore require-yield *writeTextFile() { return refuse("a file")(); @@ -801,9 +810,19 @@ 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")(); }, // deno-lint-ignore require-yield *temporaryDirectory() { diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 5adc3b8fc..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"; @@ -53,7 +54,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 +152,26 @@ 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; + /** + * 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. * @@ -233,6 +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.installing === undefined + ? {} + : { + *install(): Operation { + if (options.files !== undefined) { + yield* useRecordedFiles(options.files); + } + if (options.installing !== undefined) { + yield* options.installing(); + } + }, + }), ...(options.componentAnswers === undefined ? {} : { componentAnswers: [...options.componentAnswers] }), @@ -761,11 +791,69 @@ 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({ turns: [{ reply: COMPOSED }, { reply: CANDIDATE }], - evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()] }, + files, }); expect(run.failure).toBe(undefined); @@ -795,7 +883,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 +905,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 +932,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 +967,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 +1064,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 +1089,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 +1103,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 +1130,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 +1243,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 +1302,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 +1322,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 +1380,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..fd6c64b8c 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, @@ -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"; @@ -61,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. */ @@ -118,6 +129,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 +208,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 +514,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 +537,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 +548,373 @@ 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("/"); + }); + }); + + /** 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): Operation { + const harness = yield* planDeclarationHarness({ + surface: "component", + planWriterRoot: root, + }); + harness.fake.script({ reply: READ_REQUEST }); + harness.fake.script({ reply: PLAN }); + harness.script({ decision: "Approve" }); + return harness; + } + + /** + * The private phase runs for the invocation the engine issued, and no other. + * + * `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. + * + * 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 runs only for its own invocation", 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" }); @@ -515,7 +922,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 +971,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 +989,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 +1003,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 +1024,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 +1038,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 +1074,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 +1090,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 +1142,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 +1158,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 +1990,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 +2036,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 +2046,6 @@ describe("Tier FE — Plan produces text, Evaluate runs it", () => { description: READ_COMPONENT.description, }, ], - files, }; } @@ -1689,7 +2105,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 +2126,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 +2156,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 +2208,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 +2240,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 +2295,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..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,10 +745,31 @@ 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()], + // 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) { @@ -773,8 +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 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,277 @@ 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 { + // 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); if (admitted === undefined) { @@ -635,21 +412,29 @@ 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) => { + const outgoing = signatureOf(input, init); + return outgoing !== undefined && ceiling.has(outgoing) ? next(input, init) : false; + }, + }); + return yield* persistFetch( + { + id: expansion.id, + ...(expansion.position === undefined ? {} : { position: expansion.position }), + }, + 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 - // 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 +446,44 @@ function fetchBody( } /** - * A refusal that names the path and the direction, and nothing else. + * One request as the single string a ceiling is compared by. * - * 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. + * 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. * - * `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. + * 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* 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 }, - ); +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; } - throw error; + 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; readonly headers?: Record } | undefined, +): string | undefined { + return signature(input, init?.method ?? "GET", init?.headers); } 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..c7e78f65b --- /dev/null +++ b/packages/core/tests/shared-operation-permissions.test.ts @@ -0,0 +1,844 @@ +/** + * 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: "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", + 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; + /** The one bounded header these rows are about, as it arrived. */ + readonly bound?: 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) => { + 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"); + }); + 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([]); + }); + + /** + * 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(); + + 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..cb8373a9c 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,8 +738,17 @@ 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 { + // 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/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/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( 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..ec23c5450 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,49 @@ 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 | +| 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 @@ -12136,13 +12256,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 +12777,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 +13605,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..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,6 +716,16 @@ 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.* 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 neither consume nor reset the ten-draft and three-repair budgets, and those do @@ -1118,4 +1144,11 @@ 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 | 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 | 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 | | 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 |