Skip to content

Commit bddf524

Browse files
authored
Require opt-in for absolute host directories and preserve approved imports (#1074)
* Expose REPL mounts to Sidekick and explain mounted file reads * Teach Sidekick working-directory paths and approved external imports * 🔒 Require opt-in for absolute host directories * 🔧 Opt in absolute-path compatibility journeys * 🔧 Opt in compiled composition smoke to absolute paths
1 parent 47adc1f commit bddf524

49 files changed

Lines changed: 1231 additions & 113 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎architecture.md‎

Lines changed: 52 additions & 12 deletions
Large diffs are not rendered by default.

‎examples/adversarial-implementation/README.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ configured Git signing and verification. Run from that source checkout:
2323

2424
```bash
2525
cd /absolute/path/to/prepared-source-worktree
26-
xmd run /absolute/path/to/workflow/examples/adversarial-implementation/run.md \
26+
xmd run --allow-absolute /absolute/path/to/workflow/examples/adversarial-implementation/run.md \
2727
--props-task /absolute/path/to/task.json \
2828
--include /absolute/path/to/workflow/examples/adversarial-implementation/components --raw
2929
```
@@ -33,6 +33,10 @@ complete CLI with `xmd run /absolute/path/to/workflow/examples/adversarial-imple
3333
The task has no mode, phase, full flag or initialization override. `phase.md`
3434
and `continue.md` below serve separate assignments and existing records.
3535

36+
The workflow enters the prepared checkout and artifact directory through absolute
37+
`Dir` paths. Enable `--allow-absolute` for these runs; the flag retains existing
38+
read/write restrictions.
39+
3640
## Prepare scope and impact
3741

3842
All four closed scope groups are required. Their evidence arrays name nonempty,
@@ -178,7 +182,7 @@ files whose contents enter this prompt; artifacts remain references. A retained
178182
session uses `initialize: false`. Set it to true only for the role's first call.
179183

180184
```bash
181-
deno task xmd run examples/adversarial-implementation/phase.md \
185+
deno task xmd run --allow-absolute examples/adversarial-implementation/phase.md \
182186
--props-request /absolute/path/to/request.json \
183187
--include examples/adversarial-implementation/components --raw
184188
```
@@ -214,7 +218,7 @@ To advance a saved `Ticket.Run` or `Ticket.Continue` record, pass that complete
214218
record rather than reconstructing its assignment or artifact paths:
215219

216220
```bash
217-
deno task xmd run examples/adversarial-implementation/continue.md \
221+
deno task xmd run --allow-absolute examples/adversarial-implementation/continue.md \
218222
--props-record /absolute/path/to/ticket-record.json --props-steps 1 \
219223
--include examples/adversarial-implementation/components --raw
220224
```
@@ -346,11 +350,12 @@ health and required CI as merge gates; review acceptance waives neither.
346350
## Test without paid agents
347351

348352
```bash
349-
deno task xmd test examples/adversarial-implementation/tests/Workflow.test.md \
350-
--include examples/adversarial-implementation/tests/components \
351-
--include examples/adversarial-implementation/components
353+
deno task test packages/cli/tests/adversarial-implementation.test.ts
352354
```
353355

356+
The launcher wraps each Markdown suite in `<Testing>` and explicitly enables
357+
absolute host paths for its temporary checkouts and artifacts.
358+
354359
Only the role boundaries are replaced. These journeys use real signed scratch
355360
Git repositories, committed shell consumers, focused command failures, the
356361
production Markdown routing and the existing feedback verifier. The fake role

‎examples/adversarial-implementation/tests/Friction.test.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ pwd -P
55
```bash exec as="commandSetting"
66
printf '%s' "$XMD_EXAMPLE_COMMAND"
77
```
8-
<Let as="runner" value={commandSetting.stdout || "deno run --allow-all '" + root.replaceAll("'","'\"'\"'") + "/packages/cli/src/deno.ts' run"} />
8+
<Let as="runner" value={commandSetting.stdout || "deno run --allow-all '" + root.replaceAll("'","'\"'\"'") + "/packages/cli/src/deno.ts' run --allow-absolute"} />
99
<Let as="assessment" value={{mechanical:false,architecture:false,acceptance:true,risk:null,reason:"Inspect the saved test evidence.",evidence:["intent.md"]}} />
1010

1111
<Test name="the phase entry carries source and selected documents and needs no SHA correction" timeout="60s">

‎examples/adversarial-implementation/tests/Implementation.test.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ pwd -P
55
```bash exec as="commandSetting"
66
printf '%s' "$XMD_EXAMPLE_COMMAND"
77
```
8-
<Let as="runner" value={commandSetting.stdout || "deno run --allow-all '" + root.replaceAll("'","'\"'\"'") + "/packages/cli/src/deno.ts' run"} />
8+
<Let as="runner" value={commandSetting.stdout || "deno run --allow-all '" + root.replaceAll("'","'\"'\"'") + "/packages/cli/src/deno.ts' run --allow-absolute"} />
99

1010
<Test name="G1 G2 G3 G8 G9 normal launch preserves actual ordinary owners and captured evidence" timeout="90s">
1111
<Each in={[

‎examples/adversarial-implementation/tests/Workflow.test.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ pwd -P
55
```bash exec as="commandSetting"
66
printf '%s' "$XMD_EXAMPLE_COMMAND"
77
```
8-
<Let as="runner" value={commandSetting.stdout || "deno run --allow-all '" + suiteRoot.replaceAll("'","'\"'\"'") + "/packages/cli/src/deno.ts' run"} />
8+
<Let as="runner" value={commandSetting.stdout || "deno run --allow-all '" + suiteRoot.replaceAll("'","'\"'\"'") + "/packages/cli/src/deno.ts' run --allow-absolute"} />
99

1010
<Let as="assessment" value={{mechanical:false,architecture:false,acceptance:true,risk:null,reason:"Bounded test migration.",evidence:["/fixture/intent.md"]}} />
1111

‎packages/cli/src/cli.ts‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ import type {
8888
RootDocumentSource,
8989
} from "@executablemd/core";
9090
import { useAcpxProvider } from "@executablemd/acp";
91-
import { command as hostCommand, cwd } from "@executablemd/runtime";
91+
import { command as hostCommand, cwd, Permissions } from "@executablemd/runtime";
9292
import { runReplProgram } from "./repl/program.ts";
9393
import { decodeLocation } from "./repl/route.ts";
9494
import { renderAgentOptions, renderAgentOptionsJson, scanAgentArgs } from "./agent-options.ts";
@@ -250,7 +250,20 @@ const agentFields = {
250250
},
251251
};
252252

253+
const absoluteField = {
254+
description: "allow absolute host Dir paths within existing filesystem restrictions",
255+
...field(z.boolean(), field.default(false)),
256+
};
257+
258+
function useAbsolutePermission(allowed: boolean): Operation<void> {
259+
return Permissions.around(
260+
{ files: ([operation], next) => (operation === "absolute" ? allowed : next(operation)) },
261+
allowed ? { at: "min" } : undefined,
262+
);
263+
}
264+
253265
const executionFields = {
266+
allowAbsolute: absoluteField,
254267
include: {
255268
description: "component search directory",
256269
...field(z.array(z.string()), field.default(["components", "."]), field.array()),
@@ -326,6 +339,7 @@ const REPL_DESCRIPTION = "Run and reconstruct one XMD entry in an interactive te
326339
* their entries is not the same choice as who their entries talk to.
327340
*/
328341
const replConfig = object({
342+
allowAbsolute: absoluteField,
329343
attach: {
330344
description: "attach one interactive UI to this exact live XMD child",
331345
...field(z.string().optional()),
@@ -637,8 +651,10 @@ const REPL_HELP = [
637651
" Close/Stop. At narrow sizes the surface bar offers Sessions, Entries and",
638652
" Sidekick; only the selected outlet is mounted.",
639653
"",
640-
" --mount SOURCE:TARGET adds an external directory at a required document path.",
641-
" Repeat it: --mount /work/manuals:/reference --mount /work/fixtures:/fixtures.",
654+
" --allow-absolute permits absolute host Dir paths, subject to write and enclosing restrictions.",
655+
" It is off by default. File, File.Delete and Glob operands remain relative.",
656+
" --mount SOURCE:TARGET maps a directory; it grants no filesystem permission.",
657+
" Example: xmd repl --allow-absolute --mount /work/manuals:/reference.",
642658
" --include <directory> and --include=<directory> select component directories.",
643659
" Repeat them in search order; explicit values replace the default list [components, .].",
644660
" Relative directories resolve from the invocation working directory.",
@@ -715,8 +731,13 @@ export const REPL_VALUES: readonly string[] = [
715731
"--attach",
716732
];
717733

718-
/** The three REPL switches, which carry none. */
719-
const REPL_SWITCHES: readonly string[] = ["--approve-all", "--approve-reads", "--deny-all"];
734+
/** The REPL switches, which carry no value. */
735+
const REPL_SWITCHES: readonly string[] = [
736+
"--approve-all",
737+
"--approve-reads",
738+
"--deny-all",
739+
"--allow-absolute",
740+
];
720741

721742
export function replGrammarError(
722743
args: readonly string[],
@@ -2908,6 +2929,7 @@ function* dispatch(
29082929
}
29092930
announceSecretDetection(config.secretDetection);
29102931
const result = yield* scoped(function* (): Operation<Result<void>> {
2932+
yield* useAbsolutePermission(config.allowAbsolute);
29112933
// `<Elicit>` reaches a person through the browser form, and `xmd run`
29122934
// is the command a person is sitting in front of. Composed here, in the
29132935
// scope this profile assembles around its own document, because that is
@@ -3082,6 +3104,7 @@ function* dispatch(
30823104
if (replHost.select !== undefined) {
30833105
yield* replHost.select();
30843106
}
3107+
yield* useAbsolutePermission(command.config.allowAbsolute);
30853108
// The Sidekick's own agent selection travels with it, as the name this
30863109
// line carried and nothing more: the profile settles the fallback, and
30873110
// whether anybody answers to that name is the first explicit message's

‎packages/cli/src/documents/sidekick/entries.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,15 @@ remain local to their response; the later response does not inherit `code`.
145145

146146
An entry can ask a selected checkout four questions. Read their exact forms
147147
with `<REPL.Syntax names={["Git.Files", "Git.Changes", "Git.Diff", "Git.File"]} />`
148-
first. Enter the explicitly known checkout path before asking Git; the workspace
149-
is not a hidden route back to the project. Replace `/known/project` below with
150-
the actual known checkout path:
148+
first. Enter the selected checkout before asking Git; the workspace is not a
149+
hidden route back to the project. Acquire the repository as shown below when it
150+
is not already available in the workspace. Replace `RELATIVE_CHECKOUT` below
151+
with its observed path relative to the current working directory:
151152

152153
````xmd
153154
<Let as="code" select="code">
154155
```xmd
155-
<Dir path="/known/project">
156+
<Dir path="RELATIVE_CHECKOUT">
156157
<Git.Files prefix="packages/core/" limit={5} as="files" />
157158
<Json value={files.paths} />
158159
</Dir>
@@ -189,7 +190,13 @@ locator and creation base below with the selected repository and resolved commit
189190
Follow the receipt's read instruction and inspect the actual outcome and checkout
190191
binding. An entry can print a refusal; status alone proves no acquisition.
191192
Derive a relative checkout path from the returned absolute path and the workspace
192-
reported by `<REPL.Environment />`. Replace `RELATIVE_CHECKOUT` below with that
193+
reported by `<REPL.Environment />`. A later authored absolute Dir needs
194+
`--allow-absolute` and effective write permission; paired Repository/Worktree
195+
placement grants no such permission. Use the contained relative address when
196+
absolute is denied. An approved non-repository asset can target
197+
`RELATIVE_CHECKOUT/inputs/<filename>` beneath that same session workspace; derive
198+
that destination from the returned facts. A host repository outside the session
199+
root is unsupported by import. Replace `RELATIVE_CHECKOUT` below with that
193200
observed address, preserving the rest of this later response:
194201

195202
````xmd

‎packages/cli/src/documents/sidekick/environment.md‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@ answer.
1414
session's workspace, beside its journals. A manually submitted entry starts
1515
in the invocation's project directory. Read `<REPL.Environment />` for your
1616
current cwd; do not infer a project path from the workspace path.
17+
- Resolve relative paths against the current working directory. For example,
18+
`inputs/sales.json` names a file beneath that directory. Do not add a leading
19+
slash or reinterpret an absolute path as relative to the workspace.
20+
- Use relative paths for ordinary file and command work. Host absolute Dir
21+
selection requires the person to invoke `xmd repl --allow-absolute` and the
22+
effective filesystem policy to permit both absolute and write. A mount grants
23+
neither: new mapped absolute ensures need that opt-in too. File, File.Delete
24+
and Glob operands remain relative even when absolute directory access is allowed.
25+
When you intentionally need an external non-repository file, request approval
26+
to import it with `<REPL.ImportAsset />`, then use its returned relative path.
27+
Import remains available with absolute denied when effective read and write
28+
allow it. Its individual approval grants no subsequent absolute access.
1729
- Write session components under `components/`, copied inputs under `inputs/`,
1830
and generated files at an appropriate workspace-relative path. These files
1931
survive reopening; another session has its own directory.
@@ -46,16 +58,25 @@ answer.
4658
A directory selects `**/*.test.md` recursively and retains configured-first
4759
component lookup. Tests have effects and use the command's ordinary permissions.
4860
Read `<Guide.Entries />` for the create, test, fix and reuse sequence.
49-
- A nested `<Dir>` selects another known, authorized directory for its content;
50-
later siblings return to the workspace. To copy project input, enter its
51-
explicitly known project path, capture the read there, then write the copy
52-
after leaving Dir. Ask for the project path if you do not know it.
61+
- To change the working directory for part of a document, use a nested `<Dir>`
62+
with a known, authorized relative path. Paths inside it resolve from that
63+
directory; later siblings return to the enclosing directory. A context change
64+
grants no permission. `<REPL.ImportAsset>` always copies into the session
65+
workspace, even when a response changes its working directory.
5366
- Reopening restores acknowledged results, not file snapshots. A retained read
5467
answers with its old text; a retained write does not replace an external edit.
5568
Work performed before its acknowledgement may repeat after interruption.
5669
- This chapter teaches you how to *interpret* host facts. It states none of
5770
them, because a fact written into teaching text is a fact that goes stale.
5871
Ask for the current ones with `<REPL.Environment />`.
72+
- Read `current response policy` as the effective XMD file read/write/absolute
73+
policy for this response. Read `submitted-entry start policy (session scope;
74+
entry content may narrow it)` as the policy observed in the owner that starts
75+
submitted entries. It does not describe arbitrary nested entry content.
76+
`approved import` states eligibility under current response read/write plus
77+
separate per-file user approval, independent of absolute; eligibility is not
78+
source/destination validation or approval. These facts grant no native tools
79+
or component admission.
5980
- Treat what that read returns as the facts at the moment you asked. It is a
6081
snapshot retained with your operation, not a cache you can rely on later.
6182
- What it does not report, you do not know. An absent fact is an explicit
@@ -98,6 +119,17 @@ read. After Import succeeds, feedback gives `inputs/mock ü.html`. A later
98119
<File path="inputs/mock ü.html" />
99120
```
100121

122+
The same imported file can be read inside an explicit relative directory
123+
context. Here `mock ü.html` resolves from `inputs/`; after `Dir` ends, the
124+
enclosing working directory is restored:
125+
126+
```xmd
127+
<Dir path="inputs">
128+
<File path="mock ü.html" as="mock" />
129+
<Output>{mock}</Output>
130+
</Dir>
131+
```
132+
101133
## Common mistake
102134

103135
Writing an entry against a path, platform or capability you assumed rather than

‎packages/cli/src/repl/repositories/deno/files.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ import { createMountPolicy } from "../../mount-files.ts";
4949
import type { Operation, Result } from "effection";
5050
import { isAbsolute, join, relative, resolve } from "node:path";
5151
import { realpath } from "node:fs/promises";
52-
import { until } from "effection";
52+
import { Err, until } from "effection";
53+
import { filesFailure, Permissions } from "@executablemd/runtime";
5354
import {
5455
localRecordName,
5556
recordedLocalOperation,
@@ -143,6 +144,21 @@ function* routed(
143144
return yield* recordedLocalOperation({ operation, request, perform });
144145
}
145146

147+
if (
148+
operation === "dir-ensure" &&
149+
isAbsolute(request.path) &&
150+
(!(yield* Permissions.operations.files("write")) ||
151+
!(yield* Permissions.operations.files("absolute")))
152+
) {
153+
return Err(
154+
filesFailure({
155+
operation: "ensure-directory",
156+
phase: "resolution",
157+
reason: "permission-denied",
158+
}),
159+
);
160+
}
161+
146162
const retain = (coverage: import("../../mount-records.ts").MountCoverage) =>
147163
authored === undefined || mounts === undefined
148164
? recordedLocalOperation({ operation, request, perform })

‎packages/cli/src/repl/session.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ import type { Operation, Result, Stream, Task } from "effection";
7979
import { INLINE_SOURCE_PATH, inlineSource, validateDocument } from "@executablemd/core";
8080
import type { DocumentValidationDiagnostic, Json, PermissionMode } from "@executablemd/core";
8181
import type { GeneratedSelection } from "@executablemd/core/api";
82-
import { API } from "@executablemd/runtime";
82+
import { API, Permissions } from "@executablemd/runtime";
8383
import { executeInstalled } from "@executablemd/core/host";
8484
import { useGeneratedFallback } from "../evaluation-profile.ts";
8585
import type {
@@ -320,6 +320,12 @@ interface OwnedRootRead {
320320
read(): Operation<Result<Readonly<Record<string, Json>>>>;
321321
}
322322

323+
export interface ReplFilePolicy {
324+
readonly read: boolean;
325+
readonly write: boolean;
326+
readonly absolute: boolean;
327+
}
328+
323329
/** One execution as the application reads it. */
324330
export interface ReplSession {
325331
/** The opaque identifier this execution's history is named by. */
@@ -428,6 +434,8 @@ export interface ReplSession {
428434
* earlier position is reading history; this answers what is true now.
429435
*/
430436
bindings(): Operation<Result<Readonly<Record<string, Json>>>>;
437+
/** Observe the session scope that starts entries; content may narrow it. */
438+
filePolicy(): Operation<Result<ReplFilePolicy>>;
431439
/** Wait for the entry running in this process to finish, and answer how. */
432440
join(): Operation<Result<unknown>>;
433441
/**
@@ -1015,6 +1023,21 @@ function* start(
10151023
bindings(): Operation<Result<Readonly<Record<string, Json>>>> {
10161024
return rootData();
10171025
},
1026+
*filePolicy(): Operation<Result<ReplFilePolicy>> {
1027+
try {
1028+
return Ok(
1029+
yield* owner.run(function* () {
1030+
return Object.freeze({
1031+
read: yield* Permissions.operations.files("read"),
1032+
write: yield* Permissions.operations.files("write"),
1033+
absolute: yield* Permissions.operations.files("absolute"),
1034+
});
1035+
}),
1036+
);
1037+
} catch (error) {
1038+
return Err(error instanceof Error ? error : new Error(String(error)));
1039+
}
1040+
},
10181041
join(): Operation<Result<unknown>> {
10191042
return joining();
10201043
},

0 commit comments

Comments
 (0)