@t4r71/dsh-dual-axis 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 T4R71
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @t4r71/dsh-dual-axis
2
+
3
+ The dual-axis access-mode bundle (host half): the file sandbox's read and write axes — each `deny | workspace | all | custom`, with `custom` carrying absolute `allow` and `deny` path lists — as an installable profile layer with its Plugins-page configuration.
4
+
5
+ ## ⚠ This bundle disables an official UI row
6
+
7
+ Installing this bundle turns OFF the upstream row `ui-permission`
8
+ (`@deepseek-ai/dsh-client-ui-permission-presets`, declared in
9
+ `@deepseek-ai/dsh-web-app`'s `cordis.patch.yml`) — see `cordis.patch.yml` in this package.
10
+ That is a deliberate REPLACEMENT, not a coincidental clash, and it is not reversible by
11
+ flipping `disabled: false` while this bundle is installed.
12
+
13
+ **What is replaced.** This bundle's client half registers the same three surfaces the
14
+ upstream row registers, which is why the two cannot both be active:
15
+
16
+ | Surface | Upstream | This bundle |
17
+ | --- | --- | --- |
18
+ | Composer control | `conversation.input.permission` | same slot, two axis dropdowns instead of one preset picker |
19
+ | General-settings row | `settings.general.item`, id `permission`, order `-20` | same slot and id |
20
+ | `/permission` picker | `command.decorate({ name: 'permission' })` | same decoration, over the same command |
21
+
22
+ Two independent mechanisms would fail a client boot with both rows active, so removing
23
+ the disable is not an option:
24
+
25
+ 1. **`conversation.input.permission` is a single-occupant slot.** `ui-conversation`
26
+ declares it `{ kind: 'single', scope: 'session' }`, and a second registration throws
27
+ `single slot "<name>" already has a registration` (`packages/client/ui-slots/src/index.ts:1216-1218`).
28
+ Renaming a locale namespace does not address this.
29
+ 2. **Locale namespaces are single-occupant per (namespace, locale).** Both rows register
30
+ `permission.access` and `settings.permission`; the second `register` throws
31
+ `locale namespace "<ns>" already has locale "<locale>"`
32
+ (`packages/client/locale/src/client/index.ts:415-419`).
33
+
34
+ **Behaviour that changes.** The composer control becomes two independent read/write axis
35
+ dropdowns (`deny | workspace | all | custom`) submitting `/axis <axis>:<value>` instead of
36
+ one preset picker submitting `/permission <preset>`. The General-settings row keeps its
37
+ position and its default-preset meaning. The `/permission` slash picker keeps the upstream
38
+ preset list.
39
+
40
+ **To get the official UI back**, uninstall this bundle (drop `@t4r71/dsh-dual-axis` from the
41
+ profile's `dsh.profile.bundles` and from its `package.json` dependencies). The row that
42
+ carries the disable is this package's `cordis.patch.yml`, so it leaves with the bundle and
43
+ `ui-permission` activates again. Leaving the bundle installed and editing the row to
44
+ `disabled: false` restarts the collision above and the client hangs on "Loading plugins".
45
+
46
+ ## Model Experience
47
+
48
+ The axes are stated to the model in a runtime-context paragraph of their own, contributed under the section name `sandbox:dual-axis` (order `SANDBOX_POLICY + 1`, so it follows 0.1.7's own `sandbox:policy` line rather than interrupting it). The paragraph names both axes as resolved ranges — every allow root and every deny root, not a mode name — and states how they are enforced, including the fact that this build does not intersect them. It is re-rendered at every assembly, so a mid-session axis change reaches the model on the next request.
49
+
50
+ The paragraph is not a new model-visible input in the log sense: it is derived from the session's stored axis pair, the current definitions of the rule groups that pair references, and the session header's `cwd`, and the agent loop logs the rendered snapshot as model history like every other runtime-context entry. The refusal a fence returns is built from the same `src/scope-prompt.ts` functions, so the range the model read and the range it is denied by cannot disagree.
51
+
52
+ The axes are NOT in the session log. A package-declared event type is outside 0.1.7's `KNOWN_SESSION_EVENT_TYPES`, and `Session.append` takes no envelope option, so such an event cannot be marked `ignorable` and a log carrying it is refused whole by `validateStoredEvents` — the session becomes unopenable after a restart. The pair therefore lives in this package's own settings namespace (see Settings below), and the only axis record left in a session log is the write base mirrored onto 0.1.7's own `sandbox/mode`.
53
+
54
+ ## What 0.1.7 removed, and what this package carries instead
55
+
56
+ | Capability | 0.1.6 home | 0.1.7 state | This package |
57
+ | --- | --- | --- | --- |
58
+ | Axis algebra | `dsh-sandbox/access`, `dsh-sandbox/scope` | deleted (0 hits tree-wide) | `src/axis.ts`, `src/scope.ts` |
59
+ | Per-session axis pair | `sandbox/mode` payload `readScope`/`writeScope` | payload locked to `{ mode, source }`; a plugin event type is refused whole by the log validator | `src/session-store.ts` (settings namespace `dual-axis-sessions`), with `src/session-axes.ts` mirroring the write base onto `sandbox/mode` |
60
+ | Read-path fence | `dsh-fs-sandbox` (358 lines, 8 read overrides) | deleted; reads pass through | `src/fs-fence.ts` |
61
+ | Settings section | `settings.register(ns, schema)` | only Loader-entry Config | `src/config.ts` |
62
+ | Dual write entry | `setSandboxScopes` | only `setSandboxMode(session, mode)` | the store writes the pair; `mirrorWriteMode` writes the mode |
63
+
64
+ ## Layout
65
+
66
+ | Path | Role |
67
+ | --- | --- |
68
+ | `src/axis.ts` | The four axis kinds, the mode bijection, `effectiveScopes` |
69
+ | `src/scope.ts` | `resolveScope` / `scopeContains`: the pure allow/deny range algebra |
70
+ | `src/config.ts` | Loader-entry `Config` with both axes `.volatile()`, and `normalizeScope` |
71
+ | `src/session-store.ts` | The per-session axis pair: namespace `dual-axis-sessions`, the memoized read, the whole-document write with its revision-fence retry, and the GC sweep |
72
+ | `src/session-axes.ts` | The write base's mirror onto 0.1.7's own `sandbox/mode` event |
73
+ | `src/fs-fence.ts` | `DualAxisFileSystem`: the read-path fence |
74
+ | `src/read-guard.ts` | The `/read-guard` entry point: the tool-dispatch fence for both axes |
75
+ | `src/scope-prompt.ts` | The model-facing range text: one source for the runtime-context paragraph and the refusal |
76
+ | `src/index.ts` | The plugin class: registers the axis command and the range paragraph, seeds each new session — a subagent child from its parent's stored pair (`inheritedAxes`), every other session from the settings row's live pair — and schedules the GC sweep |
77
+ | `tests/seed-parity.spec.ts` | The cross-package drift gate: the same rows through this package's `seedPair` and through `@t4r71/dsh-dual-axis-ui`'s `sessionAxesSeed`, asserted byte for byte |
78
+ | `src/fs.ts` | The `/fs` entry point exporting the backend |
79
+
80
+ ## Settings
81
+
82
+ Two namespaces, both Loader entry ids, each field declared `.volatile()` because 0.1.7 refuses to project or write a non-volatile field.
83
+
84
+ `dual-axis` is the row the Plugins page edits: the two axes a NEW session starts from (`read`, `write`), the rule-group library (`groups`), and the group ids a new session starts out referencing (`defaultGroups`). All four are SEEDS. For a session that has been USED they are read once, at creation, and never again; for one nobody has used yet the row stays live, and editing it moves that session's axes (see "A session with no record").
85
+
86
+ A newly created session reads these values through `ctx.settings.describe()` rather than through the `Config` the plugin captured at mount: 0.1.7's loader does not commit a settings-page save back into that captured accessor, so reading it would keep seeding sessions with the value the process started on until the next restart. `describe()` re-projects the entry's live config on every call, which is the same read the settings row's own summary uses. A composition without the `settings` service falls back to the captured `Config`.
87
+
88
+ ```yaml
89
+ - id: dual-axis
90
+ name: '@t4r71/dsh-dual-axis'
91
+ config:
92
+ read: { kind: all }
93
+ write: { kind: workspace }
94
+ - id: dual-axis-sessions
95
+ name: '@t4r71/dsh-dual-axis/session-store'
96
+ ```
97
+
98
+ `dual-axis-sessions` is the store: one field, `axes`, `session id -> { read, write }`. It is a separate entry rather than a field on the row above because the row's namespace carries the Plugins-page form, and a field the row's form does not declare is one more thing that form has to render around. The client half registers no page for this namespace.
99
+
100
+ Every decision path reads the STORE, plus the group library by id, and nothing else — except for a session the store holds NO record of, where the row's seeds are the session's pair until the repair write lands (see "A session with no record"). Once a session has a record, the row's seeds are never consulted for it again. Reading is memoized on the namespace's `revision`, so a decision path pays one `settings.describe()` per document change rather than one per tool dispatch.
101
+
102
+ ### Writing, and what a lost write does
103
+
104
+ The whole `axes` field is written in one `replace`, because removing a record needs the whole map anyway. Every attempt re-reads the namespace revision first, and a refused write is retried up to four times (0/25/75/200 ms) against the revision the other writer produced. If every attempt is refused the store throws `SessionAxesConflictError` (`code: 'DUAL_AXIS_AXES_CONFLICT'`); `/axis` renders that as a command error beside the picker, and the session-creation path logs it. A write is never reported as applied when it was not stored.
105
+
106
+ ### A session with no record
107
+
108
+ A session with no record is not an error and it never hides a control. Every live decision path — the model-facing paragraph, the read fence, and `/axis` — reads through `SessionAxesStore.ensure`, which answers `seedPair` (`seedAxesFor`'s pure core): the settings row's `read`/`write` plus its `defaultGroups`, or, for a subagent child, its parent's stored pair. That value is what the session runs under and what the pickers show, so "interface shows A, host enforces B" cannot happen.
109
+
110
+ ### When the record is written
111
+
112
+ The record is written when the session is USED, not when it is created. `pin` writes at creation only for a session that already has content, and `ensure` schedules its repair write only for one that does; a session with neither a record nor any content is answered by a seed RECOMPUTED ON EVERY READ, so the settings row stays live for it.
113
+
114
+ This matters because of how a conversation actually starts. The workspace picker reopens the SAME session id when you start a new conversation in the same workspace — measured, not assumed: two consecutive "new session in nvidiaDlssGlom" clicks left `[data-conversation-session]` at the same id, and the workspace's session list was unchanged. Freezing at creation therefore pinned a conversation nobody had had to whatever the row said the day the picker first opened it, and later edits never reached it. A session that has been used freezes as before: the record is the session's own pair from then on.
115
+
116
+ **"Used" means content beyond the loop's own runtime-context snapshot** (`src/content.ts`, `hasContent`). The agent loop appends that snapshot on every turn, the first included, before any human input exists, so counting it would make every session look used the moment anything rendered its prompt. It is identified by `source.kind === 'runtime-context'`, the discriminant the loop writes and reads back, rather than by its rendered text, which is prose that changes with every contribution. Every other message counts — a real user turn, an assistant reply, the skill catalogue — because each means the session has entered a turn. The predicate walks the session's already-materialized event array with no copy or parse per event, and the sessions it is asked about are exactly the ones nobody has used, so the scan is short.
117
+
118
+ Two writes therefore freeze a pair, and both are deliberate: the first real turn, and `/axis` — which calls `SessionAxesStore.set` unconditionally, so a manual pick freezes an empty session too, and the manual value wins over any later settings-row edit.
119
+
120
+ ### Degenerate states
121
+
122
+ A repair never overwrites a record that appeared while it was waiting, and a repair that cannot be stored is logged and re-armed, never thrown at the decision path that scheduled it.
123
+
124
+ `DEFAULT_AXES` (read `all`, write `workspace`) is what `SessionAxesStore.getOr` answers for a caller that holds no session to build a seed from; it is not the pair a session with no record is held to. `getOr`'s remaining callers hold no session, because `ensure` — which the live paths use — is the one that can answer a seed. An unreadable settings row answers `DEFAULT_AXES` on BOTH sides — `seedPair` and the client half's `SESSION_AXES_FALLBACK` — so even the failure branch agrees.
125
+
126
+ The client half recomputes that same seed locally (`sessionAxesSeed` in `@t4r71/dsh-dual-axis-ui`, which shares no import with this package), because it must display the pair this host is enforcing for a session the document carries no record of. `tests/seed-parity.spec.ts` feeds both implementations the same rows and asserts the answers are the same bytes, key order included; it is the only thing that stops the two from drifting.
127
+
128
+ ### Garbage collection
129
+
130
+ Records are dropped when the session they belong to no longer exists, and the signal is the persistence layer: an id absent from `sessionPersistence.list()` has no stored log. The sweep runs at start-up (15 s in, after the loader and the persistence backend are up) and every 30 s after that. It never runs on `session/disposed` — residency churn disposes a session whose file is still on disk, and dropping that record would silently reset a session about to be resumed. A sweep that cannot judge (no persistence service, or a failed listing) removes nothing and says so in the log.
131
+
132
+ ## Building
133
+
134
+ The package resolves every `@deepseek-ai/*` import from its own `node_modules`: `pnpm install` then `tsc -b` and `tsdown` work in a directory tree that contains nothing but this package. `tsconfig.base.json` holds the compiler options this package needs and declares no `paths`; `tsconfig.json` adds only the package's own entry point.
135
+
136
+ Tests run from source through tsx (`TSX_TSCONFIG_PATH=tsconfig.runtime.json node --import tsx/esm --test tests/*.spec.ts`) and resolve the same way, with one exception: `tests/settings.spec.ts` and `tests/session-store.spec.ts` read `isVolatilePath` and `volatileForm` from `@deepseek-ai/dsh-settings/schema`, and that package ships `lib/types/schema.js` while declaring no `./schema` subpath in its exports map, so Node cannot resolve the specifier. `tsconfig.runtime.json` maps that one specifier to the shipped file inside the installed dependency. Dropping the mapping requires `@deepseek-ai/dsh-settings` to export `./schema`.
137
+
138
+ ## Known Limitations and Deferred Work
139
+
140
+ - **The write axis is NOT intersected with the read axis.** This build enforces the two axes independently: `read-guard` grades a write call with the write axis's deny list alone, and the inherited write fence grades the same call by the write axis's base mode. A path the read axis denies is therefore still writable when the write axis allows it. The client half's settings copy states the opposite (`读轴放行、写轴不放行,仍然写不了`), so the copy and the enforcement disagree. The runtime-context paragraph states the enforced behavior, not the intended invariant.
141
+ - **The read axis is enforced at the tool-dispatch layer only.** `read-guard` inspects the four read entry points in its `READ_TOOLS` table (`read`, `read_image`, `grep`, `glob`); a command tool, a child process, or a script is fenced for writes by the sandbox mode but never for reads. Mounting `DualAxisFileSystem` closes that path for `ctx.fs` consumers, which does not include command tools.
142
+ - The refusal text tells the model that reaching a refused path through another tool is not a permitted workaround. That is a rule statement; the enforcement gap above is the reason it cannot be stated as a fence. It also states the layering per axis: the read axis and either axis's allow/deny path entries bind the tool layer alone, while only a write axis's BASE tier is mirrored onto the session sandbox mode and enforced again below that layer — an `all` base mirrors to `danger-full-access` and so has no layer below.
143
+ - **A subagent child whose parent session holds no record falls back to the settings row.** `inheritedAxes` looks the parent's record up by the id the child's header names, resident or not, and returns `undefined` when that id has no record; the child is then seeded with the settings-page pair. It is still seeded, which is what keeps the read fence from falling back to the deployment defaults for that child. Residency deliberately does not participate: the client half reads the same document by the same id and cannot observe which sessions this process has materialized, so keying the seed on residency would make the two halves disagree for exactly that child.
144
+
145
+ - `DualAxisFileSystem` (`src/fs-fence.ts`) is **not mounted and not wired**. No profile composes its row — the shipped `cordis.patch.yml` carries it commented out, because mounting it means taking the `fs` service from the composition's own `fs-sandbox` row — and its per-call `ReadAxes` trailing parameter has no producer: 0.1.7's `SandboxExecutionPolicy` has no `readScope`/`writeScope` members, so a consumer that does not thread the pair gets the deployment DEFAULT read axis, never this package's per-session store. The read fence actually in force is `read-guard.ts`. The module is kept for `readAxisRefusal` / `readAxisRefusalAsync`, the canonical read-axis containment predicates, and for an operator who replaces the filesystem backend by hand (disable `fs-sandbox`, mount this row, and thread the axes).
146
+ - The client half lives in a separate package (`@t4r71/dsh-dual-axis-ui`); this package declares no `dsh.client`.
147
+
@@ -0,0 +1,68 @@
1
+ # The dual-axis bundle patch: one row inserting the dual-axis host half, with the
2
+ # two axes a newly created session starts from as its composition config.
3
+ #
4
+ # The row id `dual-axis` is load-bearing twice over in 0.1.7:
5
+ # - it is the Loader entry id, and thus the settings namespace
6
+ # (`SettingsForms.write` looks the entry up by `options.id`);
7
+ # - prefixed with this package's name it forms the `plugins.row.config` slot
8
+ # entry key the client half registers under.
9
+ #
10
+ # The browser half rides this same row: in 0.1.7 a client entry's id IS its
11
+ # package name (WebBootEntry.id, client/modules/src/client/manifest.ts:53), and
12
+ # dsh.client is one object per package, so both client surfaces — the settings
13
+ # row and the two composer dropdowns — live in the single bundle behind
14
+ # exports["./client"].
15
+
16
+ - insert:
17
+ - id: dual-axis
18
+ name: '@t4r71/dsh-dual-axis'
19
+ config:
20
+ read: { kind: all }
21
+ write: { kind: workspace }
22
+ # 文件系统半边刻意不在这里挂行:它要接管 `fs` 服务,而本体那一行已经注册了同一个
23
+ # 服务,两行同时活动会让启动直接失败(required 行不激活 = startup failed)。它同时
24
+ # 也救不了读轴 —— 0.1.7 的读路径不带会话,那一半永远拿不到要判的轴。要手工换掉
25
+ # 文件系统后端的话,得同时禁用本体的 `fs-sandbox` 行,见本包 README。
26
+
27
+ # 读轴围栏:本体 0.1.7 的读路径不带会话,读轴只能靠工具派发层执行。
28
+ - id: dual-axis-read-guard
29
+ name: '@t4r71/dsh-dual-axis/read-guard'
30
+
31
+ # 每条会话那对轴的权威存储。这是一行**独立**的 Loader entry,id 就是设置命名空间
32
+ # (0.1.7 里 ns == entry id),字段只有 `axes`:会话 id → 轴对。
33
+ #
34
+ # 为什么不塞进上面那行的 Config:那一行的 ns 绑着设置页的行表单,多一个未声明字段
35
+ # 会被投影进那张表单;而这一行的 Config 只声明 `axes` 一个 volatile 字段,
36
+ # 设置页不渲染它(客户端半边没有为它注册行)。
37
+ - id: dual-axis-sessions
38
+ name: '@t4r71/dsh-dual-axis/session-store'
39
+
40
+ # 纯客户端半边所在的包。它的宿主半边是空插件,这一行存在的唯一理由是
41
+ # 0.1.7 的客户端扫描只处理有 Loader 行的包。
42
+ - id: dual-axis-ui
43
+ name: '@t4r71/dsh-dual-axis-ui'
44
+
45
+ # 本体的权限界面行:本包的两个界面面取代它,因此必须先禁掉它。
46
+ #
47
+ # 为什么必须禁:本包的浏览器半边注册的是本体那三个界面面本身 —— 同一个
48
+ # `conversation.input.permission` 槽、同一条 `settings.general.item` 行
49
+ # (id `permission`、order -20)、同一个 `/permission` 命令装饰。两条独立的独占
50
+ # 规则因此都会在第二方 apply() 里抛错,fiber 落进 FAILED,客户端启动审计报
51
+ # 「1 entry did not activate」:
52
+ # - 槽是**单占**的:ui-conversation 把 `conversation.input.permission` 声明为
53
+ # `{ kind: 'single', scope: 'session' }`,二次注册抛
54
+ # `single slot "…" already has a registration`
55
+ # (client/ui-slots/src/index.ts:1216-1218)。改 locale 命名空间绕不开它。
56
+ # - locale 命名空间按 (ns, locale) **单占**:两侧都注册 `permission.access` 与
57
+ # `settings.permission`,二次注册抛
58
+ # `locale namespace "…" already has locale "…"`
59
+ # (client/locale/src/client/index.ts:415-419)。
60
+ # 这不是可接受的降级:取代关系必须由这个补丁表达,而不是由安装方记得少装一个包。
61
+ # 用户要拿回官方界面,得卸掉本组合包(本行随它一起走),而不是把这里改回 enabled。
62
+ #
63
+ # 这里刻意写出 name:补丁引擎在 name 与目标行不符时打一条警告并跳过
64
+ # (vendor/include/src/index.ts:115-118)。那条警告是安全阀 —— 本体改了那行的包名时,
65
+ # 补丁不再生效而不是误伤同 id 的别的行。
66
+ - id: ui-permission
67
+ name: '@deepseek-ai/dsh-client-ui-permission-presets'
68
+ disabled: true
package/lib/fs.js ADDED
@@ -0,0 +1,419 @@
1
+ import { SandboxedFileSystem } from "@deepseek-ai/dsh-fs-sandbox";
2
+ import { FsError } from "@deepseek-ai/dsh-fs";
3
+ import { stat } from "node:fs/promises";
4
+ import { dirname, sep } from "node:path";
5
+ import { canonicalPath, writableRoots } from "@deepseek-ai/dsh-sandbox";
6
+ //#region lib/types/containment.js
7
+ /**
8
+ * 目标路径包含判定:把上游 `@deepseek-ai/dsh-fs-sandbox/containment` 的
9
+ * `isPathUnder` 原样内联到本包。
10
+ *
11
+ * 为什么必须内联:fs-sandbox@0.1.7-rc.2 的**发布包** exports 只开出
12
+ * `.`、`./src/*` 与 `./package.json` 三条,**没有 `./containment`**;
13
+ * 而源码工作区里该文件存在,靠路径解析能直接命中。于是同一个 import 在
14
+ * 工作区里编译通过、装成 tgz 后运行期必然抛
15
+ * `Package subpath './containment' is not defined by "exports"`,
16
+ * 使 dual-axis 这个 entry 永远无法激活(fiberPhase 恒为 null)。
17
+ * 自有副本消除这个 install-shape 依赖。
18
+ * @module @t4r71/dsh-dual-axis/containment
19
+ */
20
+ const MISSING_CODES = /* @__PURE__ */ new Set(["ENOENT", "ENOTDIR"]);
21
+ function isMissing(error) {
22
+ const code = error.code;
23
+ return MISSING_CODES.has(code);
24
+ }
25
+ function comparablePath(path, caseSensitive) {
26
+ return caseSensitive ? path : path.toLowerCase();
27
+ }
28
+ function isLexicallyUnder$1(path, root, caseSensitive) {
29
+ const comparableTarget = comparablePath(path, caseSensitive);
30
+ const comparableRoot = comparablePath(root, caseSensitive);
31
+ if (comparableTarget === comparableRoot) return true;
32
+ const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
33
+ return comparableTarget.startsWith(prefix);
34
+ }
35
+ async function statIfPresent(path) {
36
+ try {
37
+ return await stat(path, { bigint: true });
38
+ } catch (error) {
39
+ if (isMissing(error)) return void 0;
40
+ throw error;
41
+ }
42
+ }
43
+ function sameIdentity(left, right) {
44
+ return left.dev === right.dev && left.ino === right.ino;
45
+ }
46
+ /**
47
+ * Determine whether a canonical target is a writable root or lies beneath it.
48
+ * The lexical fast path handles normal canonical spellings. When spellings
49
+ * differ, walk the target's existing ancestors and compare filesystem identity
50
+ * with the root; this recognizes Windows long-name/8.3 aliases and casing
51
+ * without weakening containment to a textual approximation.
52
+ * @param path - canonical target key, which may end in a missing suffix.
53
+ * @param root - canonical writable root.
54
+ * @param caseSensitive - whether lexical comparison preserves case; defaults
55
+ * to the host filesystem convention used by supported platforms.
56
+ * @returns whether the target is the root or a descendant of it.
57
+ */
58
+ async function isPathUnder(path, root, caseSensitive = process.platform !== "win32") {
59
+ if (isLexicallyUnder$1(path, root, caseSensitive)) return true;
60
+ const rootInfo = await statIfPresent(root);
61
+ if (!rootInfo) return false;
62
+ let ancestor = path;
63
+ while (true) {
64
+ const ancestorInfo = await statIfPresent(ancestor);
65
+ if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true;
66
+ const parent = dirname(ancestor);
67
+ if (parent === ancestor) return false;
68
+ ancestor = parent;
69
+ }
70
+ }
71
+ //#endregion
72
+ //#region lib/types/axis.js
73
+ /** The default READ axis: every mode permitted reading before the axes existed, so the whole host. */
74
+ const DEFAULT_READ_SCOPE = { kind: "all" };
75
+ /**
76
+ * The write scope a legacy `mode` means.
77
+ * @param mode - the sandbox mode.
78
+ * @returns the equivalent write scope.
79
+ */
80
+ function scopeOfMode(mode) {
81
+ switch (mode) {
82
+ case "read-only": return { kind: "deny" };
83
+ case "workspace-write": return { kind: "workspace" };
84
+ case "danger-full-access": return { kind: "all" };
85
+ }
86
+ }
87
+ /**
88
+ * Read the axis pair a resolved policy carries, falling back to the defaults.
89
+ * The policy's `mode` is authoritative for the write axis's BASE: a policy
90
+ * that carries no write axes writes exactly what its mode always meant, so
91
+ * every pre-existing policy keeps its exact behavior.
92
+ *
93
+ * 0.1.7's `SandboxExecutionPolicy` has no `readScope` / `writeScope`
94
+ * members (only `mode`, `workspaceRoot`, `sessionId?`), so the axes arrive
95
+ * through the extra argument this package's own fence passes. The
96
+ * `mode`-derived fallback keeps the function total for the upstream type.
97
+ * @param policy - the resolved policy (supplies `mode`).
98
+ * @param axes - the session's axis pair, when the caller holds one.
99
+ * @returns both axes in force.
100
+ */
101
+ function effectiveScopes(policy, axes) {
102
+ return {
103
+ read: axes?.read ?? DEFAULT_READ_SCOPE,
104
+ write: axes?.write ?? scopeOfMode(policy.mode)
105
+ };
106
+ }
107
+ //#endregion
108
+ //#region lib/types/scope.js
109
+ /**
110
+ * The shared path-range ALGEBRA behind both access axes: one pure evaluation
111
+ * from an {@link AxisScope} to the canonical allow and deny root sets the
112
+ * fence consumes.
113
+ *
114
+ * This is the 0.1.7 port of 0.1.6's `@deepseek-ai/dsh-sandbox/scope`
115
+ * (152 lines), reduced to what a fence needs and made source-agnostic:
116
+ *
117
+ * - `workspace` derives its roots from 0.1.7's own
118
+ * `writableRoots(policy)` (`@deepseek-ai/dsh-sandbox`, re-exported from
119
+ * `packages/sandbox/sandbox/src/roots.ts:52`), so the fence agrees with
120
+ * the Seatbelt profile and the write fence by construction.
121
+ * - Containment itself is NOT evaluated here. 0.1.6 took the enforcement
122
+ * layer's `contains` predicate as a parameter; this port keeps that shape
123
+ * but narrows it to the SYNCHRONOUS lexical predicate the pure tests use,
124
+ * and the filesystem-identity fallback lives in the fence
125
+ * (`fs-fence.ts`), which is where the canonical target key exists.
126
+ *
127
+ * `resolveScope` returns the sets; the fence applies them with deny-wins
128
+ * precedence through {@link scopeContains}.
129
+ *
130
+ * @module @t4r71/dsh-dual-axis/scope
131
+ */
132
+ /** Thrown when a configured `custom` scope entry cannot name an absolute path. */
133
+ var ScopeConfigError = class extends Error {
134
+ entry;
135
+ value;
136
+ constructor(entry, value) {
137
+ super(`sandbox scope: \`${entry}\` entry ${JSON.stringify(value)} must be a non-empty absolute path`);
138
+ this.entry = entry;
139
+ this.value = value;
140
+ this.name = "ScopeConfigError";
141
+ }
142
+ };
143
+ /** Canonicalize one configured custom entry, failing closed on anything that cannot name an absolute host path. */
144
+ function canonicalEntry(value, entry) {
145
+ if (value.trim().length === 0 || !isAbsoluteSpelling(value)) throw new ScopeConfigError(entry, value);
146
+ return canonicalPath(value);
147
+ }
148
+ /**
149
+ * Whether a configured path is spelled absolutely on this host. Both POSIX
150
+ * (`/x`) and Windows (`C:\\x`, `\\\\server\\share`) spellings are accepted
151
+ * because a policy may be authored for one world and resolved in another; the
152
+ * canonical resolution that follows is what actually binds it to this host.
153
+ * @param path - the configured path spelling.
154
+ * @returns whether the spelling is absolute.
155
+ */
156
+ function isAbsoluteSpelling(path) {
157
+ return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
158
+ }
159
+ /**
160
+ * Evaluate one axis against a workspace root. `deny` permits nothing;
161
+ * `workspace` yields the shared writable roots; `all` is unbounded;
162
+ * `custom` yields its base plus its own additions, with its removals listed
163
+ * separately so the fence can apply them with precedence.
164
+ *
165
+ * A `custom` scope whose base is `all` stays unbounded: "everything except
166
+ * these directories" is still everything-except, so its removals must survive
167
+ * into {@link ResolvedScope.deny} rather than being flattened into an allow
168
+ * list that could not express them.
169
+ * @param scope - the axis value.
170
+ * @param policy - the workspace root `workspace` and `custom` scopes resolve against.
171
+ * @returns the evaluated range.
172
+ * @throws {ScopeConfigError} when a `custom` entry is not a non-empty absolute path.
173
+ */
174
+ function resolveScope(scope, policy) {
175
+ if (scope.kind !== "custom") return resolveBase(scope.kind, policy);
176
+ const base = resolveBase(scope.base, policy);
177
+ const allow = [...base.allow, ...scope.allow.map((entry) => canonicalEntry(entry, "allow"))];
178
+ const deny = scope.deny.map((entry) => canonicalEntry(entry, "deny"));
179
+ return {
180
+ unbounded: base.unbounded,
181
+ allow: dedupe(allow),
182
+ deny: dedupe(deny)
183
+ };
184
+ }
185
+ /** Evaluate a base (or closed) kind — the shared half of {@link resolveScope}. */
186
+ function resolveBase(base, policy) {
187
+ switch (base) {
188
+ case "deny": return {
189
+ unbounded: false,
190
+ allow: [],
191
+ deny: []
192
+ };
193
+ case "workspace": return {
194
+ unbounded: false,
195
+ allow: dedupe(writableRoots({
196
+ mode: "workspace-write",
197
+ workspaceRoot: policy.workspaceRoot
198
+ })),
199
+ deny: []
200
+ };
201
+ case "all": return {
202
+ unbounded: true,
203
+ allow: [],
204
+ deny: []
205
+ };
206
+ }
207
+ }
208
+ /** Stable-order dedupe: keeps the first spelling of each value. */
209
+ function dedupe(values) {
210
+ return [...new Set(values)];
211
+ }
212
+ /**
213
+ * Whether `target` is the root itself or lies beneath it, by canonical
214
+ * spelling. Case-insensitive on Windows, matching that filesystem's
215
+ * convention.
216
+ * @param target - canonical target path.
217
+ * @param root - canonical root path.
218
+ * @param caseSensitive - whether lexical comparison preserves case; defaults to the host convention.
219
+ * @returns whether the target is the root or a descendant of it.
220
+ */
221
+ function isLexicallyUnder(target, root, caseSensitive = process.platform !== "win32") {
222
+ const comparableTarget = caseSensitive ? target : target.toLowerCase();
223
+ const comparableRoot = caseSensitive ? root : root.toLowerCase();
224
+ if (comparableTarget === comparableRoot) return true;
225
+ const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
226
+ return comparableTarget.startsWith(prefix);
227
+ }
228
+ /**
229
+ * Whether `targetKey` is permitted by an evaluated scope. Removal wins over
230
+ * addition: a deny root containing the target refuses it even when an allow
231
+ * root — or an unbounded axis — would otherwise permit it.
232
+ * @param scope - the evaluated scope.
233
+ * @param targetKey - the target's canonical identity key (the resolved path).
234
+ * @param contains - the containment predicate; defaults to {@link isLexicallyUnder}.
235
+ * @returns whether the scope permits the target.
236
+ */
237
+ function scopeContains(scope, targetKey, contains = isLexicallyUnder) {
238
+ for (const root of scope.deny) if (contains(targetKey, root)) return false;
239
+ if (scope.unbounded) return true;
240
+ for (const root of scope.allow) if (contains(targetKey, root)) return true;
241
+ return false;
242
+ }
243
+ //#endregion
244
+ //#region lib/types/fs-fence.js
245
+ /**
246
+ * Decide whether one read axis permits one canonical target key. This is the
247
+ * fence's entire decision, as a pure function: it takes the evaluated range and
248
+ * the containment predicate, so a test can exercise every branch with no
249
+ * filesystem, no session, and no service.
250
+ *
251
+ * The error message names the READ axis's kind, never the mode that would
252
+ * spell it: a read axis of `workspace` would otherwise be reported as
253
+ * "workspace-write", a WRITE mode name this axis has no concept of.
254
+ * @param read - the read axis in force.
255
+ * @param policy - the workspace root a `workspace` read axis derives from.
256
+ * @param targetKey - the target's canonical identity key.
257
+ * @param contains - the containment predicate.
258
+ * @returns `undefined` when permitted, or the refusal message.
259
+ */
260
+ function readAxisRefusal(read, policy, targetKey, contains) {
261
+ if (scopeContains(resolveScope(read, policy), targetKey, contains)) return void 0;
262
+ return refuse(read);
263
+ }
264
+ /**
265
+ * The async twin of {@link readAxisRefusal}: production containment
266
+ * (`isPathUnder`) walks the filesystem identity chain for alias-equivalent
267
+ * roots, so the fence cannot use the lexical predicate alone.
268
+ * @param read - the read axis in force.
269
+ * @param policy - the workspace root a `workspace` read axis derives from.
270
+ * @param targetKey - the target's canonical identity key.
271
+ * @param contains - the async containment predicate.
272
+ * @returns `undefined` when permitted, or the refusal message.
273
+ */
274
+ async function readAxisRefusalAsync(read, policy, targetKey, contains) {
275
+ const scope = resolveScope(read, policy);
276
+ for (const root of scope.deny) if (await contains(targetKey, root)) return refuse(read);
277
+ if (scope.unbounded) return void 0;
278
+ for (const root of scope.allow) if (await contains(targetKey, root)) return void 0;
279
+ return refuse(read);
280
+ }
281
+ /**
282
+ * The single refusal message both predicates return, naming the READ axis's
283
+ * kind rather than the write mode that would spell it.
284
+ * @param read - the refused read axis.
285
+ * @returns the refusal message.
286
+ */
287
+ function refuse(read) {
288
+ return `file access denied under the ${read.kind} read axis`;
289
+ }
290
+ /**
291
+ * The read-axis-enforcing filesystem backend. Mount it in place of
292
+ * `@deepseek-ai/dsh-fs-sandbox`: it registers the same `fs` service and
293
+ * inherits that backend's write fence verbatim.
294
+ */
295
+ var DualAxisFileSystem = class extends SandboxedFileSystem {
296
+ constructor(ctx, config) {
297
+ super(ctx, config);
298
+ }
299
+ /**
300
+ * The read axis pair of the session a read belongs to. Reads carry no
301
+ * session, so the fence resolves the DEPLOYMENT default axes here; a caller
302
+ * that knows the calling session passes them per call instead.
303
+ * @returns the deployment default axis pair.
304
+ */
305
+ defaultAxes() {
306
+ return effectiveScopes(this.ctx.sandboxPolicy.resolve());
307
+ }
308
+ /**
309
+ * Fence the caller's read path against the READ axis, then return the target
310
+ * for the inherited read. The check happens BEFORE any disk access.
311
+ * @param target - the target the caller already resolved.
312
+ * @param axes - the calling session's axis pair; omit for the deployment default.
313
+ * @returns the same target, once the read axis permits it.
314
+ * @throws {FsError} `FS_SANDBOX_DENIED` when the read axis refuses the target.
315
+ */
316
+ async checkedRead(target, axes) {
317
+ const policy = this.ctx.sandboxPolicy.resolve();
318
+ const refusal = await readAxisRefusalAsync(axes?.read ?? this.defaultAxes().read, policy, target.targetKey, isPathUnder);
319
+ if (refusal !== void 0) throw new FsError(`cannot read "${target.displayPath}": ${refusal}`, "FS_SANDBOX_DENIED");
320
+ return target;
321
+ }
322
+ /**
323
+ * Fence `lstat`'s string path by resolving the same target the inherited
324
+ * implementation will inspect, then applying the read axis. The resolution
325
+ * happens BEFORE the metadata read, so a refusal never touches the disk, and
326
+ * the inherited call still receives the original arguments verbatim.
327
+ * @param path - the requested path, relative to `opts.cwd` or the backend cwd.
328
+ * @param opts - optional cwd for the resolution.
329
+ * @param axes - the calling session's axis pair; omit for the deployment default.
330
+ * @returns the target the inherited `lstat` will inspect.
331
+ */
332
+ async checkedReadPath(path, opts, axes) {
333
+ return this.checkedRead(await this.resolve(path, opts), axes);
334
+ }
335
+ /**
336
+ * Fence the read by the per-call read axis, then delegate to the inherited
337
+ * UTF-8 read.
338
+ * @param target - the resolved target to read.
339
+ * @param signal - aborts the read.
340
+ * @param axes - the calling session's axis pair; omit for the deployment default.
341
+ * @returns the file content from the inherited backend.
342
+ */
343
+ async readText(target, signal, axes) {
344
+ return super.readText(await this.checkedRead(target, axes), signal);
345
+ }
346
+ /**
347
+ * Fence the read by the per-call read axis, then delegate to the inherited
348
+ * streaming read. The fence runs BEFORE the iterable is handed out: a refused
349
+ * stream never produces a chunk.
350
+ * @param target - the resolved target to stream.
351
+ * @param signal - aborts the read.
352
+ * @param axes - the calling session's axis pair; omit for the deployment default.
353
+ * @returns the inherited chunk iterable.
354
+ */
355
+ async streamText(target, signal, axes) {
356
+ return super.streamText(await this.checkedRead(target, axes), signal);
357
+ }
358
+ /**
359
+ * Fence the read by the per-call read axis, then delegate to the inherited
360
+ * bounded byte read.
361
+ * @param target - the resolved target to read.
362
+ * @param signal - aborts the read.
363
+ * @param maxBytes - inclusive byte cap on the complete content.
364
+ * @param axes - the calling session's axis pair; omit for the deployment default.
365
+ * @returns the file bytes from the inherited backend.
366
+ */
367
+ async readBytes(target, signal, maxBytes, axes) {
368
+ return super.readBytes(await this.checkedRead(target, axes), signal, maxBytes);
369
+ }
370
+ /**
371
+ * Fence the read by the per-call read axis, then delegate to the inherited
372
+ * byte-window read.
373
+ * @param target - the resolved target to read.
374
+ * @param range - `offset` and `length` of the window.
375
+ * @param signal - aborts the read.
376
+ * @param axes - the calling session's axis pair; omit for the deployment default.
377
+ * @returns the byte window from the inherited backend.
378
+ */
379
+ async readByteRange(target, range, signal, axes) {
380
+ return super.readByteRange(await this.checkedRead(target, axes), range, signal);
381
+ }
382
+ /**
383
+ * Fence the listing by the per-call read axis, then delegate to the inherited
384
+ * directory listing.
385
+ * @param target - the resolved directory to list.
386
+ * @param signal - aborts the listing.
387
+ * @param axes - the calling session's axis pair; omit for the deployment default.
388
+ * @returns the directory entries from the inherited backend.
389
+ */
390
+ async listDir(target, signal, axes) {
391
+ return super.listDir(await this.checkedRead(target, axes), signal);
392
+ }
393
+ /**
394
+ * Fence the metadata read by the per-call read axis, then delegate to the
395
+ * inherited stat.
396
+ * @param target - the resolved target to inspect.
397
+ * @param signal - aborts the metadata read.
398
+ * @param axes - the calling session's axis pair; omit for the deployment default.
399
+ * @returns the metadata, or `undefined` when the target is absent.
400
+ */
401
+ async stat(target, signal, axes) {
402
+ return super.stat(await this.checkedRead(target, axes), signal);
403
+ }
404
+ /**
405
+ * Fence the no-follow metadata read by the per-call read axis, then delegate
406
+ * to the inherited lstat with the caller's arguments unchanged.
407
+ * @param path - the requested path, relative to `opts.cwd` or the backend cwd.
408
+ * @param opts - optional cwd for the resolution.
409
+ * @param signal - aborts the metadata read.
410
+ * @param axes - the calling session's axis pair; omit for the deployment default.
411
+ * @returns the path-entry metadata, or `undefined` when the entry is absent.
412
+ */
413
+ async lstat(path, opts, signal, axes) {
414
+ await this.checkedReadPath(path, opts, axes);
415
+ return super.lstat(path, opts, signal);
416
+ }
417
+ };
418
+ //#endregion
419
+ export { DualAxisFileSystem, DualAxisFileSystem as default, readAxisRefusal, readAxisRefusalAsync };