@argszero/cordis-plugin-sandbox-grant-advisor 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 argszero
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,212 @@
1
+ # @argszero/cordis-plugin-sandbox-grant-advisor
2
+
3
+ Turns a Windows sandbox **ACL provisioning failure with no path forward** into a
4
+ diagnosis the model — and the user reading the transcript — can act on.
5
+
6
+ ```
7
+ SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
8
+ ```
9
+
10
+ Three reports describe this exact line: [discussion #7538], [discussion #7622],
11
+ [discussion #7646]. In each one every sandboxed command fails the same way,
12
+ before it runs, and the error names neither the missing right nor a remedy.
13
+
14
+ **This plugin is the stopgap for "the error does not name the outstanding
15
+ condition".** It does not repair anything: no ACL is written, no privilege is
16
+ requested, nothing is elevated.
17
+
18
+ ## The failure it recognizes
19
+
20
+ The Windows backend provisions a workspace by writing the directory's DACL and
21
+ its mandatory-integrity label in **one** `SetNamedSecurityInfoW` call
22
+ (`packages/sandbox/sandbox-windows-acl/src/acl.ts`:
23
+
24
+ ```ts
25
+ if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`)
26
+ ```
27
+
28
+ ). Two consequences follow from that one line:
29
+
30
+ 1. **The label lives in the SACL, and its half is what gets refused.** The
31
+ owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`, so the
32
+ combined apply additionally needs **`WRITE_OWNER` on the directory** — an
33
+ *object right*, which a Full-control directory (the normal workspace case)
34
+ has and a `mkdir`-created one inheriting "Authenticated Users: Modify"
35
+ (`0x1301bf`) does not. This is the backend's own documented prerequisite
36
+ ("granted directories must be caller-owned and grant `WRITE_OWNER`").
37
+ 2. **It is not `SeSecurityPrivilege`, and elevation is the wrong lever.** That
38
+ is the token-privilege form of the same idea, and it is the hypothesis the
39
+ reports naturally reach for — `whoami /priv` cannot tell the two apart,
40
+ because `WRITE_OWNER` is an object right and never appears in that table.
41
+ Granting Full control to the workspace root needs **no** elevation.
42
+
43
+ The grant is materialized lazily, on the first confined call, and **nothing is
44
+ cached when it throws** — so the same failure repeats per command (850 calls
45
+ across 39 sessions in #7622; 52,588 output tokens with no output in #7538),
46
+ which is why the loop cannot separate it from ordinary command noise.
47
+
48
+ ## What it does
49
+
50
+ One listener on the public **`tools/post-execute`** waterfall
51
+ (`@deepseek-ai/dsh-tools`). That seam — not `ctx.sandbox.confine` — because it is
52
+ the only one that has all three of: the failure text (providers propagate their
53
+ error unchanged, and the tool pipeline settles it as an `isError` result), an
54
+ agent identity to attribute it to (`exec.agent`), and a channel that speaks to
55
+ the model in the same step (`PostToolDecision`'s `additionalContexts`, which the
56
+ agent loop turns into a durable user-role message —
57
+ `packages/core/agent-loop/src/tool-calls.ts`).
58
+
59
+ 1. **One durable advisory per agent.** On the first recognized failure, the
60
+ result is enriched with a user-role notice that names the missing right, the
61
+ discriminator, and the unelevated fix:
62
+
63
+ ```
64
+ Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.
65
+
66
+ What was reported:
67
+ SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
68
+
69
+ Why it is refused while the directory looks writable: that call is a MERGED write ...
70
+ ... the label half additionally needs WRITE_OWNER on the directory. ...
71
+
72
+ Confirm the cause (unelevated) — `icacls` is a normal user command:
73
+ icacls "D:\ws"
74
+
75
+ Fix it (unelevated, one line) and then run the command again:
76
+ PowerShell: icacls "D:\ws" /grant "$env:USERNAME:(OI)(CI)F"
77
+ cmd: icacls "D:\ws" /grant "%USERNAME%:(OI)(CI)F"
78
+ ```
79
+
80
+ The notice carries its own producer-owned `source.kind`
81
+ (`sandbox-grant-advisor`) — not the retired `plugin` wrapper, which the
82
+ current session format refuses — and a bounded one-line `summary` for the
83
+ transcript row. The host log gets one matching `warn` line, so the fact
84
+ survives outside the transcript too.
85
+ 2. **An optional, bounded fail-fast half** (`enforceAfter`, default **0** =
86
+ off). It refuses a call **before dispatch** only when both hold: the
87
+ environment has failed provisioning at least `enforceAfter` times, **and**
88
+ this exact call (tool + canonical arguments) is one this plugin watched fail.
89
+ The budget is `maxDenials` (default 2), after which the call proceeds again.
90
+ The budget is per **episode of brokenness**: a call that finally succeeds stops
91
+ being a denial target and re-arms it, so an environment that breaks twice can
92
+ be refused twice — while a session can always make progress by spending the
93
+ budget it has.
94
+
95
+ ## Install
96
+
97
+ ```sh
98
+ npm install @argszero/cordis-plugin-sandbox-grant-advisor
99
+ ```
100
+
101
+ Mount it by adding the patch to your profile, or apply the shipped
102
+ `cordis.patch.yml`:
103
+
104
+ ```yaml
105
+ - insert:
106
+ - id: sandbox-grant-advisor
107
+ name: '@argszero/cordis-plugin-sandbox-grant-advisor'
108
+ ```
109
+
110
+ ## Configuration
111
+
112
+ | option | default | meaning |
113
+ | --- | --- | --- |
114
+ | `enforceAfter` | `0` | Provisioning failures in one agent after which an identical, already-failing call is refused before dispatch. `0` disables the blocking half entirely. |
115
+ | `maxDenials` | `2` | Denials one agent may spend per episode of brokenness. Bounded on purpose; re-armed when a watched call finally succeeds. |
116
+ | `include` | `[]` | Tool-name wildcard patterns to watch; empty means every tool. |
117
+ | `exclude` | `[]` | Tool-name wildcard patterns never watched. |
118
+ | `href` | — | URL quoted in the advisory as the upstream thread, instead of the discussion numbers. |
119
+
120
+ ```yaml
121
+ - set:
122
+ - id: sandbox-grant-advisor
123
+ config:
124
+ enforceAfter: 3
125
+ ```
126
+
127
+ ## What it deliberately refuses to explain
128
+
129
+ Recognition is narrow, because a classifier that names the wrong cause is worse
130
+ than one that stays silent.
131
+
132
+ - **Only the two `...NamedSecurityInfoW` operations are classified.**
133
+ `SetEntriesInAclW` merges access entries in process memory — there is no
134
+ object and no rights involved — so its failure is *not* an ACL-permission
135
+ problem, and neither are the `LocalFree`, `LockFileEx`,
136
+ `SetConsoleCtrlHandler` or `SetEnvironmentVariableW` failures thrown by the
137
+ same package.
138
+ - **The Win32 code is kept, not flattened.** `ERROR_ACCESS_DENIED` (5) is the
139
+ case the documented prerequisite explains; another code gets a different
140
+ paragraph that says so instead of borrowing the same sentence.
141
+ - **A successful command whose *output* contains the line is not a failure.**
142
+ The gate is the result's error state, not the presence of the text — reading a
143
+ log file that quotes the error must not trigger advice.
144
+ - **Only one advisory per agent.** The environment is explained once; repeating
145
+ it per failed command would be noise competing with the failure itself.
146
+
147
+ ## Honest boundaries
148
+
149
+ - **The Windows path itself cannot be witnessed on macOS**, where this plugin was
150
+ built. What the test suite proves is the decision layer — classification, the
151
+ once-per-agent rule, the fail-fast budget and its self-feeding guard, and the
152
+ wiring to a real cordis `Context` and the real `ToolRuntime` — driven by
153
+ fixtures that throw the producer's exact error shape (`Win32Error`,
154
+ `packages/subprocess/win32-process/src/errors.ts`). It does **not** prove that
155
+ `icacls ... :(OI)(CI)F` fixes a given machine; that is the user's one-line
156
+ experiment, and the advisory says so.
157
+ - **It repairs nothing and elevates nothing.** If the directory really is
158
+ Full-control for the caller, the remaining hypothesis is `SeSecurityPrivilege`
159
+ — i.e. the backend's documented prerequisite would be wrong. That is an
160
+ upstream question; the advisory states the discriminator rather than assuming
161
+ the answer.
162
+ - **Delivery to the model is the agent loop's.** `additionalContexts` are ferried
163
+ on the settled result here and appended as durable user-role events by
164
+ `agent-loop`; a direct `ctx.tools.execute()` caller with no agent gets no
165
+ advisory (and no agent to explain anything to).
166
+ - **It complements `@argszero/cordis-plugin-repeat-guard-escalation`, it does not
167
+ replace it.** That guard keys on **call identity** (identical arguments
168
+ retried); this one keys on the **environment signature**, which is how several
169
+ *different* commands share one cause. Mounting both is sensible.
170
+ - **The real fix is upstream.** `grantWrite` already computes
171
+ `hasExactGrant` / `hasExactDeny` / `hasExactLabel` and discards which one was
172
+ false, so the diagnostic that turns a 52-minute detour into one line belongs at
173
+ that site — next to the preflight the grant's lazy materialization wants.
174
+
175
+ ## Compatibility
176
+
177
+ Harness peers — all three carry the **same** range, quoted in full on purpose
178
+ (a partially quoted range admits fewer lines than the published one):
179
+
180
+ - `@deepseek-ai/dsh-llm@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0` — the only **runtime**
181
+ import: `createUserMessage` and `boundContextSummary`.
182
+ - `@deepseek-ai/dsh-agent@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0` and
183
+ `@deepseek-ai/dsh-tools@>=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0` — imported for their **types** only (`Agent`,
184
+ `ToolExecution`, `PostToolDecision`, …). Nothing is loaded from them at
185
+ runtime, but the shipped `lib/types/index.d.ts` still names them, so a consumer
186
+ on a line outside this range fails to typecheck against this package's own
187
+ declarations; that is a compatibility claim, and it is declared as a peer for
188
+ that reason.
189
+ - `@deepseek-ai/cordis@^4.0.2`.
190
+
191
+ Probed at the newest build of every line the range admits — `0.1.2-rc.1`,
192
+ `0.1.3-alpha.2`, `0.1.5-rc.3`, `0.1.6-alpha.2`, `0.1.7-rc.1` (the build the third
193
+ report ran) — with `npm run test:probe-lines`, which derives those builds from
194
+ this range, installs each one from the registry into a scratch tree and runs the
195
+ suite against it. A line whose probe fails is removed from the range rather than
196
+ left claimed.
197
+
198
+ ## Development
199
+
200
+ ```sh
201
+ npm install
202
+ npm test # tsc, then the suite (real cordis + real ToolRuntime)
203
+ npm run test:probe-lines # install the newest build of each admitted line and run the suite against it
204
+ npm run test:probe-lines -- 0.1.7-rc.1 # one line only
205
+ ```
206
+
207
+ The suite is mostly control arms: a guard that explains the wrong failure, or
208
+ refuses a call that would have worked, is worse than one that stays silent.
209
+
210
+ [discussion #7538]: https://github.com/deepseek-ai/deepseek-harness/discussions/7538
211
+ [discussion #7622]: https://github.com/deepseek-ai/deepseek-harness/discussions/7622
212
+ [discussion #7646]: https://github.com/deepseek-ai/deepseek-harness/discussions/7646
@@ -0,0 +1,51 @@
1
+ # The @argszero/cordis-plugin-sandbox-grant-advisor bundle patch: mount and go.
2
+ #
3
+ # Three reports (#7538, #7622, #7646) describe one Windows failure with no way
4
+ # forward: the host-side write grant for a sandboxed workspace cannot be
5
+ # applied, so every sandboxed command fails before it runs with
6
+ #
7
+ # SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)
8
+ #
9
+ # The grant is materialized lazily on the first confined call and caches
10
+ # nothing when it throws, so the failure repeats per command (850 calls across
11
+ # 39 sessions in #7622; 52,588 output tokens with no output in #7538). The
12
+ # error names neither the missing right nor a remedy, which is why sessions
13
+ # escape into danger-full-access or die on the model's output cap.
14
+ #
15
+ # This plugin does not repair ACLs. It observes the public
16
+ # `tools/post-execute` waterfall, recognizes that signature (and only that
17
+ # signature — see the README for what it deliberately refuses to explain), and
18
+ # attaches ONE durable user-role advisory per agent through
19
+ # `additionalContexts`, so the model gets the diagnosis in the same step as the
20
+ # failure:
21
+ #
22
+ # - the missing right is WRITE_OWNER on the directory — an object right the
23
+ # caller can grant itself, not SeSecurityPrivilege and not elevation
24
+ # (the manifest half of the merged DACL+SACL write is what needs it);
25
+ # - the directory's own ACL is the discriminator (`icacls <dir>`, looking for
26
+ # an ACE that names your SID with (F));
27
+ # - the remedy is one unelevated line:
28
+ # icacls "<dir>" /grant "$env:USERNAME:(OI)(CI)F"
29
+ #
30
+ # Optional config:
31
+ #
32
+ # - set:
33
+ # - id: sandbox-grant-advisor
34
+ # config:
35
+ # # 0 (default) = advisory only: this plugin never blocks a call.
36
+ # # Set it to enable the bounded fail-fast half: once the environment
37
+ # # has failed this way N times, an identical call this plugin has
38
+ # # WATCHED fail is refused before dispatch.
39
+ # enforceAfter: 0
40
+ # maxDenials: 2 # then step aside, so the session can finish
41
+ # exclude: [] # tool-name wildcards never watched
42
+ # href: '' # optional URL quoted in the advisory
43
+ #
44
+ # The advisory half is always on and never blocks anything. The blocking half
45
+ # is off by default on purpose: it may only refuse a call it has watched fail in
46
+ # this environment, and it is bounded by `maxDenials`, because a plugin that can
47
+ # stop command execution must never be the reason a session cannot finish.
48
+
49
+ - insert:
50
+ - id: sandbox-grant-advisor
51
+ name: '@argszero/cordis-plugin-sandbox-grant-advisor'
package/lib/advice.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * What the model — and through it the user — is told about a provisioning
3
+ * failure, and what is deliberately withheld.
4
+ *
5
+ * The text is assembled here as pure functions so every sentence can be pinned
6
+ * by a test. Two properties matter more than the wording:
7
+ *
8
+ * - **It names the right the caller is missing.** The reported failures are
9
+ * `ERROR_ACCESS_DENIED` from a *merged* DACL + SACL write. The missing right
10
+ * is `WRITE_OWNER` on the directory — an object right the caller can grant
11
+ * itself with `icacls`, unelevated. It is **not** `SeSecurityPrivilege`, the
12
+ * token privilege the reports naturally reach for; `whoami /priv` cannot show
13
+ * the difference, and elevation is the wrong lever.
14
+ * - **It gives a discriminator, not just a remedy.** Applying a fix without
15
+ * confirming the cause teaches nothing when the fix does not work. The
16
+ * one-line check (`icacls <dir>`, looking for an ACE that names the caller's
17
+ * own SID and grants `(F)`) separates "Modify-only directory" from "the
18
+ * documented prerequisite is wrong", which is the open question upstream.
19
+ *
20
+ * @module
21
+ */
22
+ import { failureLine } from './signature.js';
23
+ /** The upstream threads this advisory is a stopgap for. */
24
+ export const DISCUSSIONS = '#7538 / #7622 / #7646';
25
+ /** The documented prerequisite, quoted from the backend's README. */
26
+ export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
27
+ /** Placeholder the user replaces with the directory the error named. */
28
+ const PLACEHOLDER = '<the directory from the error line above>';
29
+ /**
30
+ * The diagnosis paragraph for one class of failure.
31
+ * @param failure - the recognized failure.
32
+ * @returns one paragraph, honest about what is and is not known.
33
+ */
34
+ function diagnosis(failure) {
35
+ switch (failure.klass) {
36
+ case 'apply-denied':
37
+ return [
38
+ 'Why it is refused while the directory looks writable: that call is a MERGED write — the DACL and the',
39
+ 'mandatory-integrity label go out as one `SetNamedSecurityInfoW`. The label lives in the SACL, and the',
40
+ "owner's implicit rights cover only READ_CONTROL and WRITE_DAC, so the label half additionally needs",
41
+ 'WRITE_OWNER on the directory. A workspace created with `mkdir` normally inherits',
42
+ '"Authenticated Users: Modify" (`0x1301bf`) from the drive root — and that mask has neither right.',
43
+ 'This is a directory ACL fact, not a token privilege: `whoami /priv` will not show it, and',
44
+ 'SeSecurityPrivilege is the wrong lever here.',
45
+ ].join('\n');
46
+ case 'read-denied':
47
+ return [
48
+ 'Why it is refused: the harness could not even read the directory\'s security descriptor, so the grant',
49
+ 'never got as far as writing one. That read wants READ_CONTROL, which the directory is not granting this',
50
+ 'account either.',
51
+ ].join('\n');
52
+ case 'apply-other':
53
+ return [
54
+ 'Why it is refused: this is the same merged write, but the Win32 code is not ERROR_ACCESS_DENIED (5), so',
55
+ 'the missing-rights story above does not apply verbatim — a missing path, a non-directory target, or a',
56
+ 'filesystem that does not carry ACLs are all possibilities. The one-line fix below is safe to try; if the',
57
+ 'code persists, it is a different failure and worth reporting with the code.',
58
+ ].join('\n');
59
+ }
60
+ }
61
+ /**
62
+ * Build the advisory attached to the failing tool result.
63
+ * @param failure - the recognized failure.
64
+ * @param href - optional URL shown for the upstream thread.
65
+ * @returns the user-role notice text, with the fix commands ready to paste.
66
+ */
67
+ export function advisoryText(failure, href) {
68
+ const path = failure.path ?? PLACEHOLDER;
69
+ const where = href === undefined ? `tracked upstream (discussions ${DISCUSSIONS})` : `tracked upstream: ${href}`;
70
+ return [
71
+ 'Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.',
72
+ '',
73
+ 'What was reported:',
74
+ ` ${failureLine(failure)}`,
75
+ '',
76
+ diagnosis(failure),
77
+ '',
78
+ 'Confirm the cause (unelevated) — `icacls` is a normal user command:',
79
+ ` icacls "${path}"`,
80
+ 'Look for an ACE that names YOUR OWN account (run `whoami` if unsure) with (F) / Full control.',
81
+ 'If the strongest entry naming you is (M) / Modify, that is this failure.',
82
+ '',
83
+ 'Fix it (unelevated, one line) and then run the command again:',
84
+ ` PowerShell: icacls "${path}" /grant "$env:USERNAME:(OI)(CI)F"`,
85
+ ` cmd: icacls "${path}" /grant "%USERNAME%:(OI)(CI)F"`,
86
+ '',
87
+ 'How to read this: the harness documents the prerequisite (' + PREREQUISITE + ') and this',
88
+ 'error does not name it yet, so the advice is delivered here instead. This is a stopgap, ' + where + '.',
89
+ 'What it is NOT: this plugin neither edits ACLs nor elevates — the command above is yours to run.',
90
+ 'Your file read/write tools still work; only sandboxed command execution is blocked.',
91
+ ].join('\n');
92
+ }
93
+ /**
94
+ * Build the pre-dispatch denial for the optional fail-fast half.
95
+ * @param failure - the recognized failure.
96
+ * @param observed - how many provisioning failures this agent has produced.
97
+ * @param denial - this denial's 1-based ordinal.
98
+ * @param maxDenials - the denial budget.
99
+ * @returns the corrective text the model receives in place of a tool result.
100
+ */
101
+ export function denialText(failure, observed, denial, maxDenials) {
102
+ const suffix = maxDenials - denial;
103
+ return [
104
+ `Blocked by sandbox-grant-advisor: this exact call has already failed ${String(observed)} times with the`,
105
+ 'same workspace-provisioning error, and the environment has not changed since:',
106
+ ` ${failureLine(failure)}`,
107
+ '',
108
+ 'Retrying cannot succeed — the sandbox cannot start a command until the directory grant applies.',
109
+ 'Stop, and either apply the fix or hand the problem to the user:',
110
+ failure.path === undefined ? '' : ` icacls "${failure.path}" /grant "$env:USERNAME:(OI)(CI)F"`,
111
+ '',
112
+ suffix > 0
113
+ ? `This is automatic block ${String(denial)} of ${String(maxDenials)}; after that the call is allowed again.`
114
+ : `This is automatic block ${String(denial)} of ${String(maxDenials)} — the last one; further identical calls are allowed again.`,
115
+ ].filter(line => line !== '').join('\n');
116
+ }
package/lib/index.js ADDED
@@ -0,0 +1,285 @@
1
+ /**
2
+ * `sandbox-grant-advisor`: turn a Windows ACL provisioning failure that has no
3
+ * path forward into a diagnosis the model — and the user reading the
4
+ * transcript — can act on.
5
+ *
6
+ * Three reports of one signature (`#7538`, `#7622`, `#7646`) describe the same
7
+ * shape: the host-side write grant for a sandboxed workspace cannot be applied,
8
+ * every sandboxed command then fails identically **before it runs**, and the
9
+ * error text is a bare Win32 line:
10
+ *
11
+ * SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
12
+ *
13
+ * The grant is materialized lazily on the first confined call and nothing is
14
+ * cached when it throws, so the failure repeats per command rather than once
15
+ * (850 calls / 39 sessions in `#7622`; 52,588 output tokens with no output in
16
+ * `#7538`). The `workspace-write` policy is simply unusable in such a
17
+ * workspace, and the remedy the backend documents — the directory must grant
18
+ * the caller `WRITE_OWNER` — never reaches the user, so sessions escape into
19
+ * `danger-full-access` or die on the model's output cap.
20
+ *
21
+ * ## Where it acts, and why there
22
+ *
23
+ * One listener on the public `tools/post-execute` waterfall
24
+ * (`@deepseek-ai/dsh-tools`). Admissibility was decided by which half of the
25
+ * defect this seam can reach: the failure text (the provider propagates its
26
+ * error unchanged, and the tool pipeline turns it into an `isError` result), an
27
+ * agent identity to attribute it to (`exec.agent`), and a channel that speaks
28
+ * to the model in the same step (`PostToolDecision`'s `additionalContexts`,
29
+ * a durable user-role message).
30
+ *
31
+ * `ctx.sandbox.confine(argv, policy, signal)` sees the failure too, and cannot
32
+ * do this: its signature carries no agent, so a wrapper could detect the
33
+ * condition and never deliver a word about it to the session that is stuck.
34
+ *
35
+ * ## What it does
36
+ *
37
+ * 1. **One durable advisory per agent.** On the first recognized provisioning
38
+ * failure, the failing tool result is enriched with a user-role notice that
39
+ * names the missing right (`WRITE_OWNER` on the directory, not
40
+ * `SeSecurityPrivilege`), gives the unelevated one-line `icacls` remedy, and
41
+ * gives the discriminator that separates a Modify-only directory from a
42
+ * wrong prerequisite. Attached through `additionalContexts`, so the model
43
+ * sees it beside the failure rather than only in a log the model never reads.
44
+ * 2. **An optional bounded fail-fast.** With `enforceAfter` set, a call this
45
+ * plugin has *watched fail* this way is refused at `tools/pre-execute` once
46
+ * the environment has failed at least that many times. It is off by default:
47
+ * the useful signal here is the diagnosis, and a plugin that blocks command
48
+ * execution for a reason it merely recognizes is a risk, not a feature. See
49
+ * the README for why the blocking half is deliberately narrow.
50
+ *
51
+ * ## Honest boundaries
52
+ *
53
+ * - **The Windows path cannot be witnessed on macOS**, where this plugin was
54
+ * built and tested. What is tested is the decision layer: classification,
55
+ * once-per-agent delivery, the fail-fast budget, and the wiring to the real
56
+ * `ToolRuntime` — against synthetic results carrying the producer's exact
57
+ * error shape, with the format taken from
58
+ * `packages/subprocess/win32-process/src/errors.ts`.
59
+ * - **It does not repair anything.** No ACL is written, no privilege is
60
+ * requested, nothing is elevated: the `icacls` line is the user's to run.
61
+ * - **It complements, rather than replaces, `repeat-guard-escalation`.** That
62
+ * guard keys on *call identity* (identical arguments retried); this one keys
63
+ * on the *environment signature*, which is how several different commands can
64
+ * share one cause. They can be mounted together.
65
+ * - **The real fix is upstream**: the failure should name the outstanding
66
+ * condition at the site that knows it (`grantWrite` computes
67
+ * `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
68
+ * false). This plugin is the stopgap.
69
+ *
70
+ * @module @argszero/cordis-plugin-sandbox-grant-advisor
71
+ */
72
+ import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
73
+ import { advisoryText, denialText, DISCUSSIONS } from './advice.js';
74
+ import { classifyProvisioningFailure } from './signature.js';
75
+ import { callKey, observe, observeSuccess, recordAdvice, recordDenial, shouldDeny } from './state.js';
76
+ export const name = 'sandbox-grant-advisor';
77
+ /** The tool pipeline this plugin observes and (optionally) gates. */
78
+ export const inject = ['tools'];
79
+ /**
80
+ * The producer kind every message this plugin writes carries.
81
+ *
82
+ * It is deliberately its own kind rather than the retired `plugin` wrapper: the
83
+ * current session format admits only a producer-owned kind — a message whose
84
+ * `source.kind` is the string `plugin` is refused on the way in
85
+ * (`packages/session/session-format-v3-to-v4/src/message-sources.ts`) — and the
86
+ * source union is documented as merge-extensible, one kind per producer.
87
+ */
88
+ export const SOURCE_KIND = 'sandbox-grant-advisor';
89
+ /** Default fail-fast threshold: 0, i.e. the blocking half is off. */
90
+ export const DEFAULT_ENFORCE_AFTER = 0;
91
+ /** Default denial budget once the blocking half is enabled. */
92
+ export const DEFAULT_MAX_DENIALS = 2;
93
+ /** Compile one `*`-wildcard pattern to an anchored RegExp; all else is literal. */
94
+ function wildcardToRegExp(pattern) {
95
+ const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw `\$&`);
96
+ return new RegExp(`^${escaped.replaceAll('*', '.*')}$`);
97
+ }
98
+ /**
99
+ * Validate a count-like option fail-loud, so a typo cannot silently disable the
100
+ * blocking half the operator asked for.
101
+ * @param label - the option name, for the message.
102
+ * @param value - the resolved value.
103
+ * @param minimum - the smallest legal value.
104
+ * @returns the value, once validated.
105
+ */
106
+ function integerAtLeast(label, value, minimum) {
107
+ if (!Number.isInteger(value) || value < minimum) {
108
+ throw new Error(`sandbox-grant-advisor: \`${label}\` must be an integer >= ${minimum} (got ${String(value)})`);
109
+ }
110
+ return value;
111
+ }
112
+ /**
113
+ * The plain text of a failed result, from the authoritative field first.
114
+ *
115
+ * `error.message` is what the producing layer recorded and survives content
116
+ * rewriting by other post-execute listeners; the rendered text is the fallback,
117
+ * so a result whose content was replaced (spill policies, hooks) is still
118
+ * classified from its message.
119
+ * @param result - the failed tool result.
120
+ * @returns the text to classify.
121
+ */
122
+ function failureText(result) {
123
+ const rendered = result.content
124
+ .filter(block => block.type === 'text')
125
+ .map(block => block.text)
126
+ .join('\n');
127
+ return result.error.message.length > 0 ? `${result.error.message}\n${rendered}` : rendered;
128
+ }
129
+ /**
130
+ * The one-line host-side account of a recognized failure.
131
+ * @param failure - the recognized failure.
132
+ * @returns a single log line.
133
+ */
134
+ function hostLine(failure) {
135
+ const where = failure.detail.length === 0 ? '' : ` at ${failure.detail}`;
136
+ return `sandbox-grant-advisor: workspace ACL provisioning failed (${failure.api} Win32 `
137
+ + `${String(failure.win32Code)})${where} — sandboxed commands will keep failing until the directory grants `
138
+ + `this account Full control; advisory delivered to the model (discussions ${DISCUSSIONS})`;
139
+ }
140
+ /**
141
+ * Wrap one notice as a user-role message.
142
+ *
143
+ * The double cast encodes a documented fact the installed type cannot express:
144
+ * the message source union is **merge-extensible** — "each producer declares its
145
+ * own `kind` in its own module; there is no shared catch-all `plugin` kind", and
146
+ * "consumers fall through unknown kinds" — while the union shipped in the peer
147
+ * package is a closed list written before this producer existed. A plugin cannot
148
+ * augment an interface it does not own, and the session format admits any
149
+ * non-empty kind except the retired `plugin` wrapper
150
+ * (`packages/session/session-format-v3-to-v4/src/message-sources.ts`), which is
151
+ * asserted by `test/plugin.spec.mjs` against the message this function returns.
152
+ * @param text - the notice body.
153
+ * @param summary - one-line account for the transcript row.
154
+ * @returns the message, identified and frozen by the harness factory.
155
+ */
156
+ function notice(text, summary) {
157
+ return createUserMessage({
158
+ content: [{ type: 'text', text }],
159
+ source: {
160
+ kind: SOURCE_KIND,
161
+ form: 'notice',
162
+ summary: boundContextSummary(summary),
163
+ },
164
+ });
165
+ }
166
+ /** Keep this plugin's notices ahead of any other context on the same result. */
167
+ function prepend(ours, theirs) {
168
+ return [ours, ...theirs ?? []];
169
+ }
170
+ /**
171
+ * Install the advisor.
172
+ * @param ctx - context carrying the tool pipeline.
173
+ * @param config - resolved options; validated fail-loud here.
174
+ */
175
+ export function apply(ctx, config = {}) {
176
+ const enforceAfter = integerAtLeast('enforceAfter', config.enforceAfter ?? DEFAULT_ENFORCE_AFTER, 0);
177
+ const maxDenials = integerAtLeast('maxDenials', config.maxDenials ?? DEFAULT_MAX_DENIALS, 1);
178
+ const includePatterns = (config.include ?? []).map(wildcardToRegExp);
179
+ const excludePatterns = (config.exclude ?? []).map(wildcardToRegExp);
180
+ const href = config.href;
181
+ /** One state per agent; a WeakMap keeps a finished agent's state collectable. */
182
+ const states = new WeakMap();
183
+ /**
184
+ * The executions this plugin denied, so the post-execute listener never reads
185
+ * its own denial as an environment failure. A denial's text quotes the Win32
186
+ * line on purpose (that is what the model must see), which makes it
187
+ * indistinguishable from the real thing by content alone — the identity of
188
+ * the execution object, shared by reference across both seams, is what
189
+ * separates them.
190
+ */
191
+ const ownDenials = new WeakSet();
192
+ /** Whether a tool participates; untracked calls are transparent. */
193
+ function tracked(toolName) {
194
+ if (includePatterns.length > 0 && !includePatterns.some(pattern => pattern.test(toolName)))
195
+ return false;
196
+ return !excludePatterns.some(pattern => pattern.test(toolName));
197
+ }
198
+ /**
199
+ * Read one settled call: advance the state, and decide whether it is the
200
+ * failure the model needs told about.
201
+ * @param exec - the call that just ran.
202
+ * @param result - its settled outcome.
203
+ * @returns the notice to attach, or undefined.
204
+ */
205
+ function inspect(exec, result) {
206
+ const agent = exec.agent;
207
+ if (agent === undefined || !tracked(exec.name))
208
+ return undefined;
209
+ if (ownDenials.has(exec)) {
210
+ ownDenials.delete(exec);
211
+ return undefined;
212
+ }
213
+ const key = callKey(exec.name, exec.arguments);
214
+ const previous = states.get(agent);
215
+ if (result.isError !== true) {
216
+ if (previous !== undefined)
217
+ states.set(agent, observeSuccess(previous, key));
218
+ return undefined;
219
+ }
220
+ const failure = classifyProvisioningFailure(failureText(result));
221
+ if (failure === undefined)
222
+ return undefined;
223
+ const advanced = observe(previous, failure, key);
224
+ if (advanced.advised) {
225
+ states.set(agent, advanced);
226
+ return undefined;
227
+ }
228
+ states.set(agent, recordAdvice(advanced));
229
+ ctx.logger.warn(hostLine(failure));
230
+ return notice(advisoryText(failure, href), `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`);
231
+ }
232
+ // Observe-and-enrich, never veto by itself: delegate first, then fold this
233
+ // plugin's notice onto whatever came back. `additionalContexts` rides both
234
+ // decision variants, so a result another listener blocked still carries the
235
+ // diagnosis.
236
+ ctx.on('tools/post-execute', async (exec, result, next) => {
237
+ let message;
238
+ try {
239
+ message = inspect(exec, result);
240
+ }
241
+ catch (error) {
242
+ // A broken advisor must not become a broken tool call.
243
+ ctx.logger.warn(`sandbox-grant-advisor: result left alone after internal error: ${String(error)}`);
244
+ }
245
+ const downstream = await next();
246
+ if (message === undefined)
247
+ return downstream;
248
+ if (downstream.kind === 'block') {
249
+ return {
250
+ kind: 'block',
251
+ feedback: downstream.feedback,
252
+ additionalContexts: prepend(message, downstream.additionalContexts),
253
+ };
254
+ }
255
+ return { ...downstream, additionalContexts: prepend(message, downstream.additionalContexts) };
256
+ });
257
+ // The optional blocking half. Registered only when asked for: with the default
258
+ // `enforceAfter: 0` this plugin never sits in a waterfall it can veto from.
259
+ if (enforceAfter === 0)
260
+ return;
261
+ ctx.on('tools/pre-execute', (exec, next) => {
262
+ try {
263
+ const agent = exec.agent;
264
+ if (agent === undefined || !tracked(exec.name))
265
+ return next();
266
+ const state = states.get(agent);
267
+ if (!shouldDeny(state, callKey(exec.name, exec.arguments), enforceAfter, maxDenials))
268
+ return next();
269
+ if (state === undefined)
270
+ return next();
271
+ // Tag before delegating, and spend the budget immediately: the denial must
272
+ // be accounted for even if a later listener replaces this decision.
273
+ ownDenials.add(exec);
274
+ states.set(agent, recordDenial(state));
275
+ return Promise.resolve({
276
+ kind: 'deny',
277
+ reason: denialText(state.last, state.observations, state.denials + 1, maxDenials),
278
+ });
279
+ }
280
+ catch (error) {
281
+ ctx.logger.warn(`sandbox-grant-advisor: call allowed after internal error: ${String(error)}`);
282
+ return next();
283
+ }
284
+ });
285
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Recognize the Windows ACL provisioning failure that has no path forward.
3
+ *
4
+ * The harness's Windows sandbox provisions a workspace by writing the
5
+ * directory's DACL and its mandatory-integrity label in **one**
6
+ * `SetNamedSecurityInfoW` call (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).
7
+ * When that call is refused, the error a session actually sees is the bare
8
+ * Win32 string — `SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)` —
9
+ * with no statement of which right was missing or what the caller can do about
10
+ * it. Every sandboxed command then fails the same way, forever, because the
11
+ * grant is materialized lazily and nothing is cached on the failure path.
12
+ *
13
+ * Recognizing the string is therefore the whole job of this module, and the
14
+ * recognition is deliberately narrow:
15
+ *
16
+ * - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
17
+ * failures are the provisioning path. `SetEntriesInAclW` merges entries in
18
+ * process memory (no object, no rights), and the `LocalFree` /
19
+ * `SetConsoleCtrlHandler` / `LockFileEx` failures in the same package are
20
+ * allocation or lock errors — advising an ACL fix for any of those would send
21
+ * a user to change the wrong thing. A classifier that names a wrong cause is
22
+ * worse than one that stays silent.
23
+ * - **The Win32 code is kept, not flattened.** `ERROR_ACCESS_DENIED` (5) is the
24
+ * case the documented prerequisite explains; another code is a different
25
+ * story and the advisory says so instead of borrowing the same sentence.
26
+ * - **The producer's detail is preserved verbatim** (`grantWrite(D:\ws)`), so
27
+ * the advisory can quote the exact line the model and the user are looking
28
+ * at, and the path can be re-used in the fix command.
29
+ *
30
+ * @module
31
+ */
32
+ /**
33
+ * The producer's format is fixed by `Win32Error`
34
+ * (`packages/subprocess/win32-process/src/errors.ts`):
35
+ * `` `${api} failed (Win32 ${code})${detail ? `: ${detail}` : ''}` ``.
36
+ * The pattern is written against that shape, but it is anchored on the API
37
+ * names rather than on surrounding text, so it survives the `Error: ` envelope
38
+ * a tool result adds and any prefix a provider wraps around it.
39
+ */
40
+ const SIGNATURE = /\b(SetNamedSecurityInfoW|GetNamedSecurityInfoW) failed \(Win32 (\d+)\)(?:: *([^\r\n]*))?/;
41
+ /** `ERROR_ACCESS_DENIED`. */
42
+ const ACCESS_DENIED = 5;
43
+ /** Split the producer's `label(path)` detail; any other shape yields nothing. */
44
+ function splitDetail(detail) {
45
+ const match = /^([A-Za-z][A-Za-z0-9_-]*)\((.*)\)$/.exec(detail.trim());
46
+ if (match === null)
47
+ return {};
48
+ const label = match[1];
49
+ const path = match[2];
50
+ if (label === undefined || path === undefined)
51
+ return {};
52
+ return { label, path };
53
+ }
54
+ /**
55
+ * Classify one failure message.
56
+ * @param message - the failure text, from the result's `error.message` or its rendered content.
57
+ * @returns the recognized failure, or undefined when this is not a provisioning failure.
58
+ */
59
+ export function classifyProvisioningFailure(message) {
60
+ const match = SIGNATURE.exec(message);
61
+ if (match === null)
62
+ return undefined;
63
+ const api = match[1];
64
+ const code = Number(match[2]);
65
+ if (api === undefined || !Number.isInteger(code))
66
+ return undefined;
67
+ const detail = (match[3] ?? '').trim();
68
+ const klass = api === 'GetNamedSecurityInfoW'
69
+ ? 'read-denied'
70
+ : code === ACCESS_DENIED ? 'apply-denied' : 'apply-other';
71
+ return { klass, api, win32Code: code, detail, ...splitDetail(detail) };
72
+ }
73
+ /**
74
+ * The one-line failure the producer wrote, for quoting back verbatim.
75
+ * @param failure - a recognized failure.
76
+ * @returns the message text a `Win32Error` would have produced.
77
+ */
78
+ export function failureLine(failure) {
79
+ const suffix = failure.detail.length === 0 ? '' : `: ${failure.detail}`;
80
+ return `${failure.api} failed (Win32 ${failure.win32Code})${suffix}`;
81
+ }
package/lib/state.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Per-agent bookkeeping: what this environment has already been told, and (in
3
+ * the optional fail-fast half) which calls have already been refused by it.
4
+ *
5
+ * The state is deliberately keyed by **agent**, not by session id string: the
6
+ * failing call carries `exec.agent`, one agent owns one session, and a WeakMap
7
+ * keyed by the agent object lets a finished session's state be collected.
8
+ *
9
+ * Two counters, two meanings — keeping them apart is what stops the plugin from
10
+ * feeding on itself:
11
+ *
12
+ * - `observations` counts *provisioning failures of this environment*, i.e.
13
+ * tool results the environment itself produced. A call this plugin denied is
14
+ * not one of them, even though its denial text quotes the Win32 line.
15
+ * - `failingKeys` holds the call identities (tool + canonical arguments) that
16
+ * have already failed this way. The fail-fast half may only refuse a call it
17
+ * has *watched fail* — never a call it merely recognizes as similar.
18
+ *
19
+ * `denials` is spent per *episode*: it is re-armed when a watched call finally
20
+ * succeeds (see `observeSuccess`), not carried for the whole session.
21
+ *
22
+ * @module
23
+ */
24
+ /**
25
+ * Canonicalize a parsed argument value into a stable string.
26
+ *
27
+ * Key order in a JavaScript object is insertion order, so two structurally
28
+ * identical calls can serialize differently depending on how the model ordered
29
+ * its JSON. Sorting keys recursively gives the identity the fail-fast half
30
+ * needs; unsupported values (functions, symbols, cycles) fall back to a type
31
+ * tag rather than throwing, because a guard must never be the reason a call
32
+ * dies.
33
+ * @param value - the parsed tool arguments.
34
+ * @returns a stable string.
35
+ */
36
+ export function canonicalize(value) {
37
+ const seen = new WeakSet();
38
+ const walk = (node, depth) => {
39
+ if (depth > 32)
40
+ return '<depth>';
41
+ if (node === null || typeof node !== 'object') {
42
+ return typeof node === 'bigint' ? `${node.toString()}n` : node;
43
+ }
44
+ if (seen.has(node))
45
+ return '<cycle>';
46
+ seen.add(node);
47
+ if (Array.isArray(node))
48
+ return node.map(item => walk(item, depth + 1));
49
+ const entries = Object.entries(node)
50
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
51
+ .map(([key, item]) => [key, walk(item, depth + 1)]);
52
+ return Object.fromEntries(entries);
53
+ };
54
+ return JSON.stringify(walk(value, 0)) ?? '<unserializable>';
55
+ }
56
+ /**
57
+ * The identity of one call: its tool name plus its canonical arguments.
58
+ * @param name - the tool name.
59
+ * @param args - the parsed arguments.
60
+ * @returns the identity key.
61
+ */
62
+ export function callKey(name, args) {
63
+ return `${name}(${canonicalize(args)})`;
64
+ }
65
+ /**
66
+ * Record one observed provisioning failure.
67
+ * @param state - the agent's current state, or undefined on first sight.
68
+ * @param failure - the recognized failure.
69
+ * @param key - the identity of the call that failed.
70
+ * @returns the updated state.
71
+ */
72
+ export function observe(state, failure, key) {
73
+ const failingKeys = new Set(state?.failingKeys ?? []);
74
+ failingKeys.add(key);
75
+ return {
76
+ observations: (state?.observations ?? 0) + 1,
77
+ last: failure,
78
+ failingKeys,
79
+ denials: state?.denials ?? 0,
80
+ advised: state?.advised ?? false,
81
+ };
82
+ }
83
+ /**
84
+ * Record that a call carrying the same identity as a previously failing one
85
+ * succeeded. The environment worked at least once for that call, so the entry
86
+ * stops justifying a denial — and is dropped rather than kept, so a later
87
+ * failure re-earns it.
88
+ *
89
+ * The denial budget is re-armed at the same moment, and only then. Measured
90
+ * consequence: without it the budget is per agent for the whole session, which
91
+ * makes the entry above unobservable — past `maxDenials` this plugin refuses
92
+ * nothing ever again, so clearing the key would change no decision. With it the
93
+ * bound reads as "at most `maxDenials` refusals per episode of brokenness": an
94
+ * environment that breaks, is repaired and breaks again may be refused again,
95
+ * while a session can always make progress by spending the budget.
96
+ * @param state - the agent's current state.
97
+ * @param key - the identity of the call that just succeeded.
98
+ * @returns the updated state, unchanged when the key was not failing.
99
+ */
100
+ export function observeSuccess(state, key) {
101
+ if (!state.failingKeys.has(key))
102
+ return state;
103
+ const failingKeys = new Set(state.failingKeys);
104
+ failingKeys.delete(key);
105
+ return { ...state, failingKeys, denials: 0 };
106
+ }
107
+ /**
108
+ * Whether a call may be refused before dispatch.
109
+ *
110
+ * Both conditions are required: the environment has failed provisioning at
111
+ * least `enforceAfter` times, **and** this exact call is one this plugin watched
112
+ * fail. The second condition is what keeps the fail-fast half from blocking a
113
+ * workaround: a different command, or the same command under a different policy
114
+ * after the user changed configuration, has no key here.
115
+ * @param state - the agent's current state, or undefined.
116
+ * @param key - the identity of the call about to dispatch.
117
+ * @param enforceAfter - the configured threshold; 0 disables the half entirely.
118
+ * @param maxDenials - the configured denial budget.
119
+ * @returns whether to deny.
120
+ */
121
+ export function shouldDeny(state, key, enforceAfter, maxDenials) {
122
+ if (enforceAfter === 0 || state === undefined)
123
+ return false;
124
+ if (state.observations < enforceAfter)
125
+ return false;
126
+ if (state.denials >= maxDenials)
127
+ return false;
128
+ return state.failingKeys.has(key);
129
+ }
130
+ /**
131
+ * Spend one denial.
132
+ * @param state - the agent's current state.
133
+ * @returns the updated state.
134
+ */
135
+ export function recordDenial(state) {
136
+ return { ...state, denials: state.denials + 1 };
137
+ }
138
+ /**
139
+ * Mark the durable advisory as delivered.
140
+ * @param state - the agent's current state.
141
+ * @returns the updated state.
142
+ */
143
+ export function recordAdvice(state) {
144
+ return { ...state, advised: true };
145
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What the model — and through it the user — is told about a provisioning
3
+ * failure, and what is deliberately withheld.
4
+ *
5
+ * The text is assembled here as pure functions so every sentence can be pinned
6
+ * by a test. Two properties matter more than the wording:
7
+ *
8
+ * - **It names the right the caller is missing.** The reported failures are
9
+ * `ERROR_ACCESS_DENIED` from a *merged* DACL + SACL write. The missing right
10
+ * is `WRITE_OWNER` on the directory — an object right the caller can grant
11
+ * itself with `icacls`, unelevated. It is **not** `SeSecurityPrivilege`, the
12
+ * token privilege the reports naturally reach for; `whoami /priv` cannot show
13
+ * the difference, and elevation is the wrong lever.
14
+ * - **It gives a discriminator, not just a remedy.** Applying a fix without
15
+ * confirming the cause teaches nothing when the fix does not work. The
16
+ * one-line check (`icacls <dir>`, looking for an ACE that names the caller's
17
+ * own SID and grants `(F)`) separates "Modify-only directory" from "the
18
+ * documented prerequisite is wrong", which is the open question upstream.
19
+ *
20
+ * @module
21
+ */
22
+ import type { ProvisioningFailure } from './signature.js';
23
+ /** The upstream threads this advisory is a stopgap for. */
24
+ export declare const DISCUSSIONS = "#7538 / #7622 / #7646";
25
+ /** The documented prerequisite, quoted from the backend's README. */
26
+ export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
27
+ /**
28
+ * Build the advisory attached to the failing tool result.
29
+ * @param failure - the recognized failure.
30
+ * @param href - optional URL shown for the upstream thread.
31
+ * @returns the user-role notice text, with the fix commands ready to paste.
32
+ */
33
+ export declare function advisoryText(failure: ProvisioningFailure, href?: string): string;
34
+ /**
35
+ * Build the pre-dispatch denial for the optional fail-fast half.
36
+ * @param failure - the recognized failure.
37
+ * @param observed - how many provisioning failures this agent has produced.
38
+ * @param denial - this denial's 1-based ordinal.
39
+ * @param maxDenials - the denial budget.
40
+ * @returns the corrective text the model receives in place of a tool result.
41
+ */
42
+ export declare function denialText(failure: ProvisioningFailure, observed: number, denial: number, maxDenials: number): string;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `sandbox-grant-advisor`: turn a Windows ACL provisioning failure that has no
3
+ * path forward into a diagnosis the model — and the user reading the
4
+ * transcript — can act on.
5
+ *
6
+ * Three reports of one signature (`#7538`, `#7622`, `#7646`) describe the same
7
+ * shape: the host-side write grant for a sandboxed workspace cannot be applied,
8
+ * every sandboxed command then fails identically **before it runs**, and the
9
+ * error text is a bare Win32 line:
10
+ *
11
+ * SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
12
+ *
13
+ * The grant is materialized lazily on the first confined call and nothing is
14
+ * cached when it throws, so the failure repeats per command rather than once
15
+ * (850 calls / 39 sessions in `#7622`; 52,588 output tokens with no output in
16
+ * `#7538`). The `workspace-write` policy is simply unusable in such a
17
+ * workspace, and the remedy the backend documents — the directory must grant
18
+ * the caller `WRITE_OWNER` — never reaches the user, so sessions escape into
19
+ * `danger-full-access` or die on the model's output cap.
20
+ *
21
+ * ## Where it acts, and why there
22
+ *
23
+ * One listener on the public `tools/post-execute` waterfall
24
+ * (`@deepseek-ai/dsh-tools`). Admissibility was decided by which half of the
25
+ * defect this seam can reach: the failure text (the provider propagates its
26
+ * error unchanged, and the tool pipeline turns it into an `isError` result), an
27
+ * agent identity to attribute it to (`exec.agent`), and a channel that speaks
28
+ * to the model in the same step (`PostToolDecision`'s `additionalContexts`,
29
+ * a durable user-role message).
30
+ *
31
+ * `ctx.sandbox.confine(argv, policy, signal)` sees the failure too, and cannot
32
+ * do this: its signature carries no agent, so a wrapper could detect the
33
+ * condition and never deliver a word about it to the session that is stuck.
34
+ *
35
+ * ## What it does
36
+ *
37
+ * 1. **One durable advisory per agent.** On the first recognized provisioning
38
+ * failure, the failing tool result is enriched with a user-role notice that
39
+ * names the missing right (`WRITE_OWNER` on the directory, not
40
+ * `SeSecurityPrivilege`), gives the unelevated one-line `icacls` remedy, and
41
+ * gives the discriminator that separates a Modify-only directory from a
42
+ * wrong prerequisite. Attached through `additionalContexts`, so the model
43
+ * sees it beside the failure rather than only in a log the model never reads.
44
+ * 2. **An optional bounded fail-fast.** With `enforceAfter` set, a call this
45
+ * plugin has *watched fail* this way is refused at `tools/pre-execute` once
46
+ * the environment has failed at least that many times. It is off by default:
47
+ * the useful signal here is the diagnosis, and a plugin that blocks command
48
+ * execution for a reason it merely recognizes is a risk, not a feature. See
49
+ * the README for why the blocking half is deliberately narrow.
50
+ *
51
+ * ## Honest boundaries
52
+ *
53
+ * - **The Windows path cannot be witnessed on macOS**, where this plugin was
54
+ * built and tested. What is tested is the decision layer: classification,
55
+ * once-per-agent delivery, the fail-fast budget, and the wiring to the real
56
+ * `ToolRuntime` — against synthetic results carrying the producer's exact
57
+ * error shape, with the format taken from
58
+ * `packages/subprocess/win32-process/src/errors.ts`.
59
+ * - **It does not repair anything.** No ACL is written, no privilege is
60
+ * requested, nothing is elevated: the `icacls` line is the user's to run.
61
+ * - **It complements, rather than replaces, `repeat-guard-escalation`.** That
62
+ * guard keys on *call identity* (identical arguments retried); this one keys
63
+ * on the *environment signature*, which is how several different commands can
64
+ * share one cause. They can be mounted together.
65
+ * - **The real fix is upstream**: the failure should name the outstanding
66
+ * condition at the site that knows it (`grantWrite` computes
67
+ * `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
68
+ * false). This plugin is the stopgap.
69
+ *
70
+ * @module @argszero/cordis-plugin-sandbox-grant-advisor
71
+ */
72
+ import type { Context } from '@deepseek-ai/cordis';
73
+ export declare const name = "sandbox-grant-advisor";
74
+ /** The tool pipeline this plugin observes and (optionally) gates. */
75
+ export declare const inject: string[];
76
+ /**
77
+ * The producer kind every message this plugin writes carries.
78
+ *
79
+ * It is deliberately its own kind rather than the retired `plugin` wrapper: the
80
+ * current session format admits only a producer-owned kind — a message whose
81
+ * `source.kind` is the string `plugin` is refused on the way in
82
+ * (`packages/session/session-format-v3-to-v4/src/message-sources.ts`) — and the
83
+ * source union is documented as merge-extensible, one kind per producer.
84
+ */
85
+ export declare const SOURCE_KIND = "sandbox-grant-advisor";
86
+ /** Default fail-fast threshold: 0, i.e. the blocking half is off. */
87
+ export declare const DEFAULT_ENFORCE_AFTER = 0;
88
+ /** Default denial budget once the blocking half is enabled. */
89
+ export declare const DEFAULT_MAX_DENIALS = 2;
90
+ /** Configures what is watched and whether the blocking half runs. */
91
+ export interface Config {
92
+ /**
93
+ * Provisioning failures after which an identical, already-failing call is
94
+ * denied before dispatch. `0` (the default) disables the half entirely; the
95
+ * advisory half is unaffected and always on.
96
+ */
97
+ enforceAfter?: number;
98
+ /**
99
+ * How many denials one agent may spend. Defaults to 2. Bounded on purpose:
100
+ * an unbounded refusal turns a stuck session into an unfinishable one.
101
+ */
102
+ maxDenials?: number;
103
+ /** Tool-name wildcard patterns to watch; empty means every tool. */
104
+ include?: string[];
105
+ /** Tool-name wildcard patterns never watched. */
106
+ exclude?: string[];
107
+ /** URL quoted in the advisory as the upstream thread; optional. */
108
+ href?: string;
109
+ }
110
+ /**
111
+ * Install the advisor.
112
+ * @param ctx - context carrying the tool pipeline.
113
+ * @param config - resolved options; validated fail-loud here.
114
+ */
115
+ export declare function apply(ctx: Context, config?: Config): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Recognize the Windows ACL provisioning failure that has no path forward.
3
+ *
4
+ * The harness's Windows sandbox provisions a workspace by writing the
5
+ * directory's DACL and its mandatory-integrity label in **one**
6
+ * `SetNamedSecurityInfoW` call (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).
7
+ * When that call is refused, the error a session actually sees is the bare
8
+ * Win32 string — `SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)` —
9
+ * with no statement of which right was missing or what the caller can do about
10
+ * it. Every sandboxed command then fails the same way, forever, because the
11
+ * grant is materialized lazily and nothing is cached on the failure path.
12
+ *
13
+ * Recognizing the string is therefore the whole job of this module, and the
14
+ * recognition is deliberately narrow:
15
+ *
16
+ * - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
17
+ * failures are the provisioning path. `SetEntriesInAclW` merges entries in
18
+ * process memory (no object, no rights), and the `LocalFree` /
19
+ * `SetConsoleCtrlHandler` / `LockFileEx` failures in the same package are
20
+ * allocation or lock errors — advising an ACL fix for any of those would send
21
+ * a user to change the wrong thing. A classifier that names a wrong cause is
22
+ * worse than one that stays silent.
23
+ * - **The Win32 code is kept, not flattened.** `ERROR_ACCESS_DENIED` (5) is the
24
+ * case the documented prerequisite explains; another code is a different
25
+ * story and the advisory says so instead of borrowing the same sentence.
26
+ * - **The producer's detail is preserved verbatim** (`grantWrite(D:\ws)`), so
27
+ * the advisory can quote the exact line the model and the user are looking
28
+ * at, and the path can be re-used in the fix command.
29
+ *
30
+ * @module
31
+ */
32
+ /** Which provisioning operation failed, and which diagnosis follows from it. */
33
+ export type FailureClass =
34
+ /** `SetNamedSecurityInfoW` returned `ERROR_ACCESS_DENIED` (5): the merged DACL + label write was refused. */
35
+ 'apply-denied'
36
+ /** `SetNamedSecurityInfoW` failed with a Win32 code other than `ERROR_ACCESS_DENIED`. */
37
+ | 'apply-other'
38
+ /** `GetNamedSecurityInfoW` failed: the security descriptor could not even be read. */
39
+ | 'read-denied';
40
+ /** One recognized provisioning failure, with the producer's own fields kept. */
41
+ export interface ProvisioningFailure {
42
+ /** Which diagnosis follows from the api/code pair. */
43
+ readonly klass: FailureClass;
44
+ /** The API whose checked result failed, exactly as the producer names it. */
45
+ readonly api: string;
46
+ /** The Win32 error code as reported. */
47
+ readonly win32Code: number;
48
+ /** The producer's detail, e.g. `grantWrite(D:\ws)`; empty when it supplied none. */
49
+ readonly detail: string;
50
+ /** The detail's `label(...)` head, when it has that shape. */
51
+ readonly label?: string;
52
+ /** The directory the detail names, when it has that shape. */
53
+ readonly path?: string;
54
+ }
55
+ /**
56
+ * Classify one failure message.
57
+ * @param message - the failure text, from the result's `error.message` or its rendered content.
58
+ * @returns the recognized failure, or undefined when this is not a provisioning failure.
59
+ */
60
+ export declare function classifyProvisioningFailure(message: string): ProvisioningFailure | undefined;
61
+ /**
62
+ * The one-line failure the producer wrote, for quoting back verbatim.
63
+ * @param failure - a recognized failure.
64
+ * @returns the message text a `Win32Error` would have produced.
65
+ */
66
+ export declare function failureLine(failure: ProvisioningFailure): string;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Per-agent bookkeeping: what this environment has already been told, and (in
3
+ * the optional fail-fast half) which calls have already been refused by it.
4
+ *
5
+ * The state is deliberately keyed by **agent**, not by session id string: the
6
+ * failing call carries `exec.agent`, one agent owns one session, and a WeakMap
7
+ * keyed by the agent object lets a finished session's state be collected.
8
+ *
9
+ * Two counters, two meanings — keeping them apart is what stops the plugin from
10
+ * feeding on itself:
11
+ *
12
+ * - `observations` counts *provisioning failures of this environment*, i.e.
13
+ * tool results the environment itself produced. A call this plugin denied is
14
+ * not one of them, even though its denial text quotes the Win32 line.
15
+ * - `failingKeys` holds the call identities (tool + canonical arguments) that
16
+ * have already failed this way. The fail-fast half may only refuse a call it
17
+ * has *watched fail* — never a call it merely recognizes as similar.
18
+ *
19
+ * `denials` is spent per *episode*: it is re-armed when a watched call finally
20
+ * succeeds (see `observeSuccess`), not carried for the whole session.
21
+ *
22
+ * @module
23
+ */
24
+ import type { ProvisioningFailure } from './signature.js';
25
+ /** Everything the plugin remembers about one agent. */
26
+ export interface AgentState {
27
+ /** Provisioning failures observed for this agent. */
28
+ observations: number;
29
+ /** The most recent failure, for the denial text. */
30
+ last: ProvisioningFailure;
31
+ /** Identity keys of the calls that failed this way. */
32
+ failingKeys: Set<string>;
33
+ /** Denials already spent. */
34
+ denials: number;
35
+ /** Whether the durable advisory has been delivered for this agent. */
36
+ advised: boolean;
37
+ }
38
+ /**
39
+ * Canonicalize a parsed argument value into a stable string.
40
+ *
41
+ * Key order in a JavaScript object is insertion order, so two structurally
42
+ * identical calls can serialize differently depending on how the model ordered
43
+ * its JSON. Sorting keys recursively gives the identity the fail-fast half
44
+ * needs; unsupported values (functions, symbols, cycles) fall back to a type
45
+ * tag rather than throwing, because a guard must never be the reason a call
46
+ * dies.
47
+ * @param value - the parsed tool arguments.
48
+ * @returns a stable string.
49
+ */
50
+ export declare function canonicalize(value: unknown): string;
51
+ /**
52
+ * The identity of one call: its tool name plus its canonical arguments.
53
+ * @param name - the tool name.
54
+ * @param args - the parsed arguments.
55
+ * @returns the identity key.
56
+ */
57
+ export declare function callKey(name: string, args: unknown): string;
58
+ /**
59
+ * Record one observed provisioning failure.
60
+ * @param state - the agent's current state, or undefined on first sight.
61
+ * @param failure - the recognized failure.
62
+ * @param key - the identity of the call that failed.
63
+ * @returns the updated state.
64
+ */
65
+ export declare function observe(state: AgentState | undefined, failure: ProvisioningFailure, key: string): AgentState;
66
+ /**
67
+ * Record that a call carrying the same identity as a previously failing one
68
+ * succeeded. The environment worked at least once for that call, so the entry
69
+ * stops justifying a denial — and is dropped rather than kept, so a later
70
+ * failure re-earns it.
71
+ *
72
+ * The denial budget is re-armed at the same moment, and only then. Measured
73
+ * consequence: without it the budget is per agent for the whole session, which
74
+ * makes the entry above unobservable — past `maxDenials` this plugin refuses
75
+ * nothing ever again, so clearing the key would change no decision. With it the
76
+ * bound reads as "at most `maxDenials` refusals per episode of brokenness": an
77
+ * environment that breaks, is repaired and breaks again may be refused again,
78
+ * while a session can always make progress by spending the budget.
79
+ * @param state - the agent's current state.
80
+ * @param key - the identity of the call that just succeeded.
81
+ * @returns the updated state, unchanged when the key was not failing.
82
+ */
83
+ export declare function observeSuccess(state: AgentState, key: string): AgentState;
84
+ /**
85
+ * Whether a call may be refused before dispatch.
86
+ *
87
+ * Both conditions are required: the environment has failed provisioning at
88
+ * least `enforceAfter` times, **and** this exact call is one this plugin watched
89
+ * fail. The second condition is what keeps the fail-fast half from blocking a
90
+ * workaround: a different command, or the same command under a different policy
91
+ * after the user changed configuration, has no key here.
92
+ * @param state - the agent's current state, or undefined.
93
+ * @param key - the identity of the call about to dispatch.
94
+ * @param enforceAfter - the configured threshold; 0 disables the half entirely.
95
+ * @param maxDenials - the configured denial budget.
96
+ * @returns whether to deny.
97
+ */
98
+ export declare function shouldDeny(state: AgentState | undefined, key: string, enforceAfter: number, maxDenials: number): boolean;
99
+ /**
100
+ * Spend one denial.
101
+ * @param state - the agent's current state.
102
+ * @returns the updated state.
103
+ */
104
+ export declare function recordDenial(state: AgentState): AgentState;
105
+ /**
106
+ * Mark the durable advisory as delivered.
107
+ * @param state - the agent's current state.
108
+ * @returns the updated state.
109
+ */
110
+ export declare function recordAdvice(state: AgentState): AgentState;
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@argszero/cordis-plugin-sandbox-grant-advisor",
3
+ "description": "Turns the Windows sandbox's ACL provisioning failure into a diagnosis with a path forward. Three reports (#7538, #7622, #7646) describe one signature — every sandboxed command fails before it runs with `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)` — and the error names neither the missing right nor a remedy, so the loop burns tokens and sessions escape into danger-full-access. The host-side grant is materialized lazily and caches nothing on the failure path, so the same failure repeats per command. This plugin observes the public `tools/post-execute` waterfall, classifies that signature (only the two `...NamedSecurityInfoW` operations, keeping the Win32 code and the producer's own detail verbatim), and attaches ONE durable user-role advisory per agent through `additionalContexts`: the missing right is WRITE_OWNER on the directory (an object right the caller can self-grant), not SeSecurityPrivilege and not elevation; the directory's current ACL is the discriminator; the fix is one unelevated `icacls` line. An optional, off-by-default `enforceAfter` refuses an identical call this plugin has watched fail, bounded by `maxDenials`. It never edits an ACL and never elevates, and it complements repeat-guard-escalation, which keys on call identity rather than on the environment signature.",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./src/*": "./src/*",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib/**/*.js",
18
+ "lib/types/**/*.d.ts",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/argszero/cordis-plugin-sandbox-grant-advisor.git"
27
+ },
28
+ "homepage": "https://github.com/argszero/cordis-plugin-sandbox-grant-advisor#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/argszero/cordis-plugin-sandbox-grant-advisor/issues"
31
+ },
32
+ "keywords": [
33
+ "cordis",
34
+ "deepseek-harness",
35
+ "dsh",
36
+ "plugin",
37
+ "sandbox",
38
+ "windows",
39
+ "acl",
40
+ "win32",
41
+ "diagnostics",
42
+ "guard"
43
+ ],
44
+ "dsh": {
45
+ "bundle": {
46
+ "patch": "./cordis.patch.yml"
47
+ }
48
+ },
49
+ "peerDependencies": {
50
+ "@deepseek-ai/cordis": "^4.0.2",
51
+ "@deepseek-ai/dsh-agent": ">=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0",
52
+ "@deepseek-ai/dsh-llm": ">=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0",
53
+ "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0 || >=0.1.3-alpha.2 <0.2.0 || >=0.1.5-alpha.1 <0.2.0 || >=0.1.6-alpha.1 <0.2.0 || >=0.1.7-alpha.1 <0.2.0"
54
+ },
55
+ "devDependencies": {
56
+ "@deepseek-ai/cordis": "^4.0.2",
57
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.1",
58
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.1",
59
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.1",
60
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.1",
61
+ "@types/node": "^22.10.2",
62
+ "semver": "^7.6.0",
63
+ "typescript": "^5.5.0"
64
+ },
65
+ "scripts": {
66
+ "build": "tsc",
67
+ "pretest": "tsc",
68
+ "test": "node --test \"test/*.spec.mjs\"",
69
+ "test:probe-lines": "node scripts/probe-lines.mjs",
70
+ "prepublishOnly": "tsc"
71
+ },
72
+ "engines": {
73
+ "node": "^22.19 || >=24"
74
+ }
75
+ }