@argszero/cordis-plugin-sandbox-grant-advisor 0.1.0 → 0.3.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/lib/advice.js CHANGED
@@ -1,29 +1,77 @@
1
1
  /**
2
- * What the model — and through it the user — is told about a provisioning
3
- * failure, and what is deliberately withheld.
2
+ * What the model — and through it the user — is told about a recognized
3
+ * environment failure, and what is deliberately withheld.
4
4
  *
5
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:
6
+ * by a test, one family at a time. The two families are shaped by the same two
7
+ * questions, and they answer them differently:
7
8
  *
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.
9
+ * - **The ACL failure** (`acl-provisioning`) *is* fixable by the caller, so its
10
+ * advice names the right the caller is missing and gives the command.
11
+ * The reported failures are `ERROR_ACCESS_DENIED` from a *merged* DACL + SACL
12
+ * write; the missing right is `WRITE_OWNER` on the directory — an object right
13
+ * the caller can grant itself with `icacls`, unelevated. It is **not**
14
+ * `SeSecurityPrivilege`, the token privilege the reports naturally reach for;
15
+ * `whoami /priv` cannot show the difference, and elevation is the wrong lever.
16
+ * - **The persistent-shell failure** (`pty-startup`) is *not* fixable by the
17
+ * caller — least of all by the model, which has no shell to run anything in.
18
+ * So its advice says so and stops: the remedy is a user-side preset choice,
19
+ * and the model's instruction is to stop retrying and use its file tools.
20
+ * Handing the model a command here would be advice to run something that
21
+ * cannot run, and naming a one-shot shell tool would be advice to call a tool
22
+ * the failing composition does not mount.
23
+ *
24
+ * Both give a **discriminator, not just a remedy**: applying a fix without
25
+ * confirming the cause teaches nothing when the fix does not work. For the ACL
26
+ * family that is `icacls <dir>`, looking for an ACE that names the caller's own
27
+ * SID and grants `(F)` — which separates "Modify-only directory" from "the
28
+ * documented prerequisite is wrong", the open question upstream. For the PTY
29
+ * family it is the **effective sandbox mode**, which is why that advisory is
30
+ * only ever built with the mode the call actually ran under.
19
31
  *
20
32
  * @module
21
33
  */
22
34
  import { failureLine } from './signature.js';
23
- /** The upstream threads this advisory is a stopgap for. */
24
- export const DISCUSSIONS = '#7538 / #7622 / #7646';
35
+ /** The upstream threads the ACL advisory is a stopgap for. */
36
+ export const ACL_DISCUSSIONS = '#7538 / #7622 / #7646 / #7720';
37
+ /** The upstream thread the persistent-shell advisory is a stopgap for. */
38
+ export const PTY_DISCUSSIONS = '#7638';
25
39
  /** The documented prerequisite, quoted from the backend's README. */
26
40
  export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
41
+ /**
42
+ * Where a user's own preset changes actually live.
43
+ *
44
+ * This is deliberately **not** the legacy `$DSH_HOME/.agent-presets/<id>/`
45
+ * directory: that shape predates declarative presets and **nothing reads it any
46
+ * more** (the registry "neither scans directories nor accepts preset paths").
47
+ * A preset is a `@deepseek-ai/dsh-agent-preset` row, and changing one means
48
+ * overriding or inserting that row in a patch layer, which is what this path
49
+ * names. Advising a folder the harness stopped reading would be the same defect
50
+ * this plugin exists to answer — a remedy that does not work, delivered
51
+ * confidently.
52
+ */
53
+ export const PROFILE_PATCH = '$DSH_HOME/profiles/<profile>/cordis.patch.yml';
54
+ /** The machine-wide patch layer, for a change that should hold in every profile. */
55
+ export const GLOBAL_PATCH = '$DSH_HOME/cordis.patch.yml';
56
+ /** The row id the shipped `minimal` preset is declared under. */
57
+ export const MINIMAL_PRESET_ROW = 'preset-minimal';
58
+ /** The one-shot shell tool the `standard` preset mounts on Windows. */
59
+ export const ONE_SHOT_SHELL = '@deepseek-ai/dsh-tool-pwsh';
60
+ /**
61
+ * The two remedies that look like the fix and are not.
62
+ *
63
+ * Both were applied by the reporter of `#7720` before finding the one that
64
+ * works, and both are the *natural* reach: making yourself the owner and
65
+ * resetting the directory's ACL are how one normally repairs a Windows
66
+ * permission problem. They fail here for two different reasons, and naming the
67
+ * reason is what makes this section worth its lines — a reader who already
68
+ * tried them learns why, and a reader who has not is spared the attempt. See
69
+ * {@link nonFixes} for when this is emitted.
70
+ */
71
+ export const NOT_FIXES = [
72
+ 'takeown /F "<dir>" /R /D Y',
73
+ 'icacls "<dir>" /reset /T /C',
74
+ ];
27
75
  /** Placeholder the user replaces with the directory the error named. */
28
76
  const PLACEHOLDER = '<the directory from the error line above>';
29
77
  /**
@@ -58,15 +106,66 @@ function diagnosis(failure) {
58
106
  ].join('\n');
59
107
  }
60
108
  }
109
+ /**
110
+ * The "this is not the fix" lines for one class of failure.
111
+ *
112
+ * Emitted only for `apply-denied`, the class whose whole diagnosis is the
113
+ * missing `WRITE_OWNER` right — because only there is the claim true:
114
+ *
115
+ * - `read-denied` wants `READ_CONTROL`, and taking ownership *does* carry it,
116
+ * so calling `takeown` a non-fix there would be false.
117
+ * - `apply-other` already says the missing-rights story does not apply
118
+ * verbatim, so a section that presupposes it would contradict its own
119
+ * diagnosis.
120
+ *
121
+ * A negative claim still has to be earned: the failure to avoid is advice that
122
+ * is confidently wrong in the other direction.
123
+ * @param failure - the recognized provisioning failure.
124
+ * @param path - the directory the error named, or the placeholder.
125
+ * @returns the section's lines, or an empty array for a class it does not fit.
126
+ */
127
+ function nonFixes(failure, path) {
128
+ if (failure.klass !== 'apply-denied')
129
+ return [];
130
+ return [
131
+ 'What will NOT fix it — both look like the right move, and both were tried and reported:',
132
+ ` takeown /F "${path}" /R /D Y`,
133
+ " makes you the owner, but ownership's implicit rights are READ_CONTROL and WRITE_DAC only.",
134
+ ' The owner does not implicitly hold WRITE_OWNER, which is the right this call needs.',
135
+ ` icacls "${path}" /reset /T /C`,
136
+ ' restores inheritance, and inheritance is what supplied the Modify-only ACE above.',
137
+ '',
138
+ ];
139
+ }
61
140
  /**
62
141
  * Build the advisory attached to the failing tool result.
142
+ *
143
+ * The family decides everything: one function so a caller does not have to
144
+ * remember which family needs which fact, and so the mode requirement of the
145
+ * PTY family is enforced by construction rather than by convention.
146
+ * @param failure - the recognized failure.
147
+ * @param context - what the caller knows about the failing call.
148
+ * @returns the user-role notice text, with any remedy ready to paste.
149
+ * @throws when a PTY failure is advised without its resolved sandbox mode.
150
+ */
151
+ export function advisoryText(failure, context = {}) {
152
+ if (failure.family === 'pty-startup') {
153
+ if (context.mode === undefined) {
154
+ throw new Error('sandbox-grant-advisor: the persistent-shell advisory requires the resolved sandbox mode');
155
+ }
156
+ return ptyAdvisory(failure, context.mode, context.tool, context.href);
157
+ }
158
+ return aclAdvisory(failure, context.href);
159
+ }
160
+ /**
161
+ * Build the advisory for a workspace-provisioning failure.
63
162
  * @param failure - the recognized failure.
64
163
  * @param href - optional URL shown for the upstream thread.
65
164
  * @returns the user-role notice text, with the fix commands ready to paste.
66
165
  */
67
- export function advisoryText(failure, href) {
166
+ function aclAdvisory(failure, href) {
68
167
  const path = failure.path ?? PLACEHOLDER;
69
- const where = href === undefined ? `tracked upstream (discussions ${DISCUSSIONS})` : `tracked upstream: ${href}`;
168
+ const where = href === undefined ? `tracked upstream (discussions ${ACL_DISCUSSIONS})` : `tracked upstream: ${href}`;
70
169
  return [
71
170
  'Sandbox provisioning failed — no sandboxed command can run in this workspace until its ACL applies.',
72
171
  '',
@@ -84,14 +183,74 @@ export function advisoryText(failure, href) {
84
183
  ` PowerShell: icacls "${path}" /grant "$env:USERNAME:(OI)(CI)F"`,
85
184
  ` cmd: icacls "${path}" /grant "%USERNAME%:(OI)(CI)F"`,
86
185
  '',
186
+ ...nonFixes(failure, path),
87
187
  'How to read this: the harness documents the prerequisite (' + PREREQUISITE + ') and this',
88
188
  'error does not name it yet, so the advice is delivered here instead. This is a stopgap, ' + where + '.',
89
189
  'What it is NOT: this plugin neither edits ACLs nor elevates — the command above is yours to run.',
90
190
  'Your file read/write tools still work; only sandboxed command execution is blocked.',
91
191
  ].join('\n');
92
192
  }
193
+ /**
194
+ * Build the advisory for a persistent-shell startup failure.
195
+ *
196
+ * The one thing this text must never do is hand the model a command to run:
197
+ * there is no shell to run it in. That is why the remedy is addressed to the
198
+ * user (preset choice), while the model's instruction is to stop — the
199
+ * alternative, naming a one-shot shell tool, would be advice to call a tool the
200
+ * failing composition does not mount (`minimal` mounts exactly one platform
201
+ * shell, the persistent PTY: the design note
202
+ * `.agents/notes/implemented/simplification/2026-09-03-minimal-profiles-persistent-shell-only.md`).
203
+ * @param failure - the recognized failure.
204
+ * @param mode - the resolved sandbox mode the failing call ran under.
205
+ * @param tool - the tool whose call failed, when the caller knows it.
206
+ * @param href - optional URL shown for the upstream thread.
207
+ * @returns the user-role notice text.
208
+ */
209
+ function ptyAdvisory(failure, mode, tool, href) {
210
+ const where = href === undefined ? `tracked upstream (discussion ${PTY_DISCUSSIONS})` : `tracked upstream: ${href}`;
211
+ const call = tool === undefined ? 'This tool' : `The \`${tool}\` tool`;
212
+ return [
213
+ 'Persistent shell failed to start — command execution is unavailable in this session, and retrying cannot fix it.',
214
+ '',
215
+ 'What was reported:',
216
+ ` ${failureLine(failure)}`,
217
+ '',
218
+ `${call} is a PERSISTENT PTY session (a shell that stays alive between calls), and this session's sandbox mode is`,
219
+ `\`${mode}\` — not \`danger-full-access\`. A confining mode spawns the shell through the sandbox, and there the`,
220
+ 'terminal backend cannot create the pseudo-console at all, so the child exits before its first prompt. The same',
221
+ 'shell works under `danger-full-access`, and the one-shot shell tool works under the same confining mode:',
222
+ 'persistent PTY × confining sandbox is the combination that fails.',
223
+ '',
224
+ 'Do NOT retry, and do not look for a command that fixes it: every attempt will fail identically, and there is no',
225
+ 'shell to run a command in. Use your file read/write tools instead, and hand the choice below to the user.',
226
+ '',
227
+ 'What unblocks the session — the user\'s decision, not the model\'s:',
228
+ ' 1. switch the agent preset to `standard`, whose shell tool is a one-shot subprocess (no PTY) and works',
229
+ ' under the sandbox; or',
230
+ ` 2. override the \`${MINIMAL_PRESET_ROW}\` row in your profile patch — \`${PROFILE_PATCH}\`, or`,
231
+ ` \`${GLOBAL_PATCH}\` for every profile — replacing its \`persistent-shell\` group with`,
232
+ ` \`${ONE_SHOT_SHELL}\` (a one-shot subprocess, no PTY); the patch layer is yours, so an upgrade`,
233
+ ' will not overwrite it; or',
234
+ ' 3. run the session with `danger-full-access`, which drops the very confinement the sandbox exists to give.',
235
+ ' Prefer 1 or 2.',
236
+ '',
237
+ 'How to read this: the failure names no cause and points at no remedy, so the diagnosis is delivered here instead.',
238
+ 'This is a stopgap, ' + where + '. Unless the mode is `danger-full-access`, this plugin stays silent, because a',
239
+ 'shell can fail to start for other reasons and a confident wrong cause is worse than no answer.',
240
+ ].join('\n');
241
+ }
93
242
  /**
94
243
  * Build the pre-dispatch denial for the optional fail-fast half.
244
+ *
245
+ * The blocking half is deliberately **ACL-only**, and this function's parameter
246
+ * type is where that is enforced. The PTY family gets an advisory and nothing
247
+ * else, for a reason that is about the remedy rather than about the failure:
248
+ * the ACL remedy is a command the user can run *while the session continues*,
249
+ * so refusing further identical calls cannot make the session unfinishable —
250
+ * spending the budget always lets the call through, and a repaired environment
251
+ * is discovered by exactly that. The PTY remedy is a preset swap, which happens
252
+ * between sessions; refusing calls could only pad a session that is already
253
+ * unable to do the thing being refused.
95
254
  * @param failure - the recognized failure.
96
255
  * @param observed - how many provisioning failures this agent has produced.
97
256
  * @param denial - this denial's 1-based ordinal.
package/lib/index.js CHANGED
@@ -1,23 +1,45 @@
1
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.
2
+ * `sandbox-grant-advisor`: turn an environment failure that has no path forward
3
+ * into a diagnosis the model — and the user reading the transcript — can act on.
5
4
  *
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:
5
+ * ## The two failures it recognizes
6
+ *
7
+ * **Workspace provisioning (Windows ACL).** Four reports of one signature
8
+ * (`#7538`, `#7622`, `#7646`, `#7720`) describe the same shape: the host-side write grant
9
+ * for a sandboxed workspace cannot be applied, every sandboxed command then
10
+ * fails identically **before it runs**, and the error text is a bare Win32 line:
10
11
  *
11
12
  * SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
12
13
  *
13
14
  * The grant is materialized lazily on the first confined call and nothing is
14
15
  * cached when it throws, so the failure repeats per command rather than once
15
16
  * (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
17
+ * `#7538`). The remedy the backend documents — the directory must grant the
18
+ * caller `WRITE_OWNER` — never reaches the user, so sessions escape into
19
19
  * `danger-full-access` or die on the model's output cap.
20
20
  *
21
+ * `#7720` sharpens where this lands: because the grant is materialized at
22
+ * sandbox *initialization*, that failure takes **every** shell tool with it, not
23
+ * one operation — the reporter could not run `netstat` or `icacls` to diagnose
24
+ * the failure they were looking at. It also contributes the two remedies that
25
+ * look right and are not (`takeown /R /D Y`, `icacls /reset /T /C`), which the
26
+ * ACL advisory now names along with the reason each fails.
27
+ *
28
+ * **Persistent shell startup (#7638).** With the `minimal` preset on Windows the
29
+ * only shell tool is a persistent PTY (`dsh-terminal-bash` +
30
+ * `dsh-tool-pwsh-persistent`), and under a *confining* sandbox mode every call
31
+ * fails instantly with
32
+ *
33
+ * PTY shell exited during startup
34
+ *
35
+ * — the backend cannot create the pseudo-console inside the sandbox, so the
36
+ * child exits before its first prompt. Retrying never helps, the message points
37
+ * at no cause, and because `minimal` mounts no fallback shell tool the session
38
+ * has no command execution left at all. The reporter's own three-arm control
39
+ * makes the sandbox mode the discriminator: minimal × confining fails, minimal ×
40
+ * `danger-full-access` succeeds, `standard` (one-shot shell) × confining
41
+ * succeeds.
42
+ *
21
43
  * ## Where it acts, and why there
22
44
  *
23
45
  * One listener on the public `tools/post-execute` waterfall
@@ -28,51 +50,74 @@
28
50
  * to the model in the same step (`PostToolDecision`'s `additionalContexts`,
29
51
  * a durable user-role message).
30
52
  *
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.
53
+ * `ctx.sandbox.confine(argv, policy, signal)` sees the confinement failure too,
54
+ * and cannot do this: its signature carries no agent, so a wrapper could detect
55
+ * the condition and never deliver a word about it to the session that is stuck.
56
+ *
57
+ * The PTY family needs one fact the failure text does not carry — the effective
58
+ * sandbox mode — and takes it from `ctx.sandboxPolicy.resolve({ session })`:
59
+ * the same resolver the terminal layer calls before spawning, with the same
60
+ * session. See `src/mode.ts` for why that lookup is guarded rather than
61
+ * imported, and what happens when it cannot answer.
34
62
  *
35
63
  * ## What it does
36
64
  *
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.
65
+ * 1. **One durable advisory per agent, per family.** On the first recognized
66
+ * failure of a family, the failing tool result is enriched with a user-role
67
+ * notice. For the ACL family it names the missing right (`WRITE_OWNER` on the
68
+ * directory, not `SeSecurityPrivilege`), gives the unelevated one-line
69
+ * `icacls` remedy, gives the discriminator that separates a Modify-only
70
+ * directory from a wrong prerequisite, and names the two remedies that look
71
+ * right and are not (`takeown`, `icacls /reset`), each with its reason. For the PTY family it names the
72
+ * combination that fails (persistent PTY × a confining mode), states the
73
+ * resolved mode, says plainly that no command can fix it, and hands the
74
+ * user-side preset choice over. Both ride `additionalContexts`, so the model
75
+ * sees the diagnosis beside the failure rather than only in a log it never
76
+ * reads.
77
+ * 2. **A bounded fail-fast, ACL family only.** With `enforceAfter` set, a call
78
+ * this plugin has *watched fail* this way is refused at `tools/pre-execute`
79
+ * once the environment has failed at least that many times. It is off by
80
+ * default: the useful signal here is the diagnosis, and a plugin that blocks
81
+ * command execution for a reason it merely recognizes is a risk, not a
82
+ * feature. See the README for why the blocking half is deliberately narrow
83
+ * and why it does not cover the PTY family.
84
+ * 3. **A disclosure when it withholds.** The PTY advisory is only sent when the
85
+ * resolved mode actually confines; if the mode is not confining, or cannot be
86
+ * resolved at all, the failure is left exactly as it was **and the host log
87
+ * says so once**. Silence alone would make "the sandbox is not the cause" and
88
+ * "this plugin could not tell" indistinguishable from the outside.
50
89
  *
51
90
  * ## Honest boundaries
52
91
  *
53
92
  * - **The Windows path cannot be witnessed on macOS**, where this plugin was
54
93
  * 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`.
94
+ * once-per-agent-per-family delivery, the sandbox-mode gate and its
95
+ * fail-closed behaviour, the fail-fast budget, and the wiring to the real
96
+ * `ToolRuntime` — against synthetic results carrying the producers' exact
97
+ * error shapes, with the formats taken from
98
+ * `packages/subprocess/win32-process/src/errors.ts` and
99
+ * `packages/terminal/terminal-bash/src/{index,session}.ts`.
59
100
  * - **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.
101
+ * requested, nothing is elevated, no preset is installed and no mode is
102
+ * changed: both remedies are the user's to apply.
61
103
  * - **It complements, rather than replaces, `repeat-guard-escalation`.** That
62
104
  * guard keys on *call identity* (identical arguments retried); this one keys
63
105
  * on the *environment signature*, which is how several different commands can
64
106
  * 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
107
+ * - **The real fix is upstream**, in both families: the ACL failure should name
108
+ * the outstanding condition at the site that knows it (`grantWrite` computes
67
109
  * `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
68
- * false). This plugin is the stopgap.
110
+ * false), and the PTY startup path should either report "this sandbox mode is
111
+ * incompatible with the PTY backend" or fall back to a one-shot shell. This
112
+ * plugin is the stopgap.
69
113
  *
70
114
  * @module @argszero/cordis-plugin-sandbox-grant-advisor
71
115
  */
72
116
  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';
117
+ import { advisoryText, ACL_DISCUSSIONS, denialText, PTY_DISCUSSIONS } from './advice.js';
118
+ import { classifyProvisioningFailure, classifyPtyStartupFailure } from './signature.js';
119
+ import { confines, resolveSandboxMode } from './mode.js';
120
+ import { advisedOf, callKey, observe, observeSuccess, recordAdvice, recordDenial, recordWithheld, shouldDeny, } from './state.js';
76
121
  export const name = 'sandbox-grant-advisor';
77
122
  /** The tool pipeline this plugin observes and (optionally) gates. */
78
123
  export const inject = ['tools'];
@@ -90,6 +135,14 @@ export const SOURCE_KIND = 'sandbox-grant-advisor';
90
135
  export const DEFAULT_ENFORCE_AFTER = 0;
91
136
  /** Default denial budget once the blocking half is enabled. */
92
137
  export const DEFAULT_MAX_DENIALS = 2;
138
+ /**
139
+ * The family the optional blocking half applies to.
140
+ *
141
+ * The ACL remedy is a command the user can run while the session continues; the
142
+ * PTY remedy is a preset swap between sessions. Refusing calls is only useful
143
+ * in the first case — see `denialText` in `src/advice.ts`.
144
+ */
145
+ export const ENFORCED_FAMILY = 'acl-provisioning';
93
146
  /** Compile one `*`-wildcard pattern to an anchored RegExp; all else is literal. */
94
147
  function wildcardToRegExp(pattern) {
95
148
  const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw `\$&`);
@@ -127,15 +180,25 @@ function failureText(result) {
127
180
  return result.error.message.length > 0 ? `${result.error.message}\n${rendered}` : rendered;
128
181
  }
129
182
  /**
130
- * The one-line host-side account of a recognized failure.
183
+ * The one-line host-side account of a recognized ACL failure.
131
184
  * @param failure - the recognized failure.
132
185
  * @returns a single log line.
133
186
  */
134
- function hostLine(failure) {
187
+ function aclHostLine(failure) {
135
188
  const where = failure.detail.length === 0 ? '' : ` at ${failure.detail}`;
136
189
  return `sandbox-grant-advisor: workspace ACL provisioning failed (${failure.api} Win32 `
137
190
  + `${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})`;
191
+ + `this account Full control; advisory delivered to the model (discussions ${ACL_DISCUSSIONS})`;
192
+ }
193
+ /**
194
+ * The one-line host-side account of a recognized persistent-shell failure.
195
+ * @param mode - the resolved sandbox mode the failing call ran under.
196
+ * @returns a single log line.
197
+ */
198
+ function ptyHostLine(mode) {
199
+ return `sandbox-grant-advisor: persistent shell exited during startup under sandbox mode `
200
+ + `"${mode}" — a PTY backend cannot start under a confining mode, and retrying cannot help; advisory `
201
+ + `delivered to the model (discussion ${PTY_DISCUSSIONS})`;
139
202
  }
140
203
  /**
141
204
  * Wrap one notice as a user-role message.
@@ -167,6 +230,12 @@ function notice(text, summary) {
167
230
  function prepend(ours, theirs) {
168
231
  return [ours, ...theirs ?? []];
169
232
  }
233
+ /** The one-line transcript summary for a recognized failure. */
234
+ function summaryOf(failure, mode) {
235
+ return failure.family === 'pty-startup'
236
+ ? `persistent shell exited during startup under sandbox mode "${String(mode)}"`
237
+ : `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`;
238
+ }
170
239
  /**
171
240
  * Install the advisor.
172
241
  * @param ctx - context carrying the tool pipeline.
@@ -195,6 +264,26 @@ export function apply(ctx, config = {}) {
195
264
  return false;
196
265
  return !excludePatterns.some(pattern => pattern.test(toolName));
197
266
  }
267
+ /**
268
+ * Leave a recognized failure exactly as it is, and say so once on the host.
269
+ *
270
+ * Withholding is a decision, not an absence: the transcript shows a bare error
271
+ * either way, so the difference between "this is not the sandbox's doing" and
272
+ * "this plugin could not tell" has to be recorded where a maintainer reads it.
273
+ * Once per agent, because a loop can produce dozens of these.
274
+ * @param agent - the agent whose failure was withheld.
275
+ * @param state - the agent's state, to keep the note to one.
276
+ * @param why - what stopped the advisory.
277
+ * @returns undefined, so callers can `return withhold(...)`.
278
+ */
279
+ function withhold(agent, state, why) {
280
+ if (state?.withheld === true)
281
+ return undefined;
282
+ states.set(agent, recordWithheld(state));
283
+ ctx.logger.warn(`sandbox-grant-advisor: persistent-shell startup failure recognized but no advisory sent — ${why}; the raw `
284
+ + `error is left exactly as it is, so this is NOT a claim that the sandbox is unrelated (discussion ${PTY_DISCUSSIONS})`);
285
+ return undefined;
286
+ }
198
287
  /**
199
288
  * Read one settled call: advance the state, and decide whether it is the
200
289
  * failure the model needs told about.
@@ -217,17 +306,81 @@ export function apply(ctx, config = {}) {
217
306
  states.set(agent, observeSuccess(previous, key));
218
307
  return undefined;
219
308
  }
220
- const failure = classifyProvisioningFailure(failureText(result));
309
+ // The two families read different fields, on purpose. The ACL signature
310
+ // carries an API name plus a Win32 code, which a command's own output does
311
+ // not fabricate, so that family may read the merged text (`error.message`
312
+ // with the rendered content as its fallback). The persistent-shell
313
+ // signature is a bare sentence, and the rendered content is exactly where a
314
+ // runner-failure path could carry a command's output — so this family reads
315
+ // `error.message` alone, the field the layer that threw it filled in. A
316
+ // sentence quoted from a log must never make this plugin tell a working
317
+ // session that its shell is dead.
318
+ //
319
+ // Honest about the limit: with the current `dsh-tools` runtime the rendered
320
+ // content of an error result is derived from `error.message`, so today the
321
+ // two reads agree and this choice is not observable from outside — an
322
+ // injection arm that swaps in the merged text leaves the suite green, and
323
+ // that is recorded rather than papered over. The narrower read is kept
324
+ // because the agreement is the runtime's rendering choice, not a promise
325
+ // this plugin can rely on: a tool whose `render` produces output of its own
326
+ // is exactly the case the whole-line rule exists for.
327
+ const failure = classifyProvisioningFailure(failureText(result))
328
+ ?? classifyPtyStartupFailure(result.error.message);
221
329
  if (failure === undefined)
222
330
  return undefined;
223
- const advanced = observe(previous, failure, key);
224
- if (advanced.advised) {
225
- states.set(agent, advanced);
226
- return undefined;
331
+ // The PTY diagnosis IS the sandbox mode, so it is resolved before anything
332
+ // is recorded: an unconfined mode is not this family's story, and a mode
333
+ // that cannot be resolved is not something to guess at. Either way the
334
+ // failure is left untouched and the host log accounts for the silence.
335
+ if (failure.family === 'pty-startup') {
336
+ const resolution = resolveSandboxMode(ctx, agent);
337
+ if (!resolution.ok)
338
+ return withhold(agent, previous, resolution.withheld);
339
+ const mode = resolution.mode;
340
+ if (!confines(mode)) {
341
+ return withhold(agent, previous, `the failing call ran under \`${mode}\`, where the shell is not spawned through the sandbox`);
342
+ }
343
+ if (!claimAdvice(agent, previous, failure, key))
344
+ return undefined;
345
+ ctx.logger.warn(ptyHostLine(mode));
346
+ return notice(advisoryText(failure, advisoryContext(exec.name, mode)), summaryOf(failure, mode));
227
347
  }
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)})`);
348
+ if (!claimAdvice(agent, previous, failure, key))
349
+ return undefined;
350
+ ctx.logger.warn(aclHostLine(failure));
351
+ return notice(advisoryText(failure, advisoryContext(exec.name)), summaryOf(failure));
352
+ }
353
+ /**
354
+ * Record one recognized failure and claim the once-per-agent advisory for its
355
+ * family.
356
+ * @param agent - the agent whose call failed.
357
+ * @param previous - the agent's state before this call, if any.
358
+ * @param failure - the recognized failure.
359
+ * @param key - the identity of the failing call.
360
+ * @returns true when this call is the one that must carry the diagnosis.
361
+ */
362
+ function claimAdvice(agent, previous, failure, key) {
363
+ const advanced = observe(previous, failure.family, failure, key);
364
+ const first = !advisedOf(advanced, failure.family);
365
+ states.set(agent, first ? recordAdvice(advanced, failure.family) : advanced);
366
+ return first;
367
+ }
368
+ /**
369
+ * The advisory context for one failing call.
370
+ *
371
+ * Built here rather than at each call site so the optional fields are only
372
+ * present when they are known — `exactOptionalPropertyTypes` would otherwise
373
+ * accept an explicit `undefined` that the consumer would have to un-learn.
374
+ * @param tool - the failing tool's name.
375
+ * @param mode - the resolved sandbox mode, for the family that needs it.
376
+ * @returns the context to pass to `advisoryText`.
377
+ */
378
+ function advisoryContext(tool, mode) {
379
+ return {
380
+ ...href === undefined ? {} : { href },
381
+ tool,
382
+ ...mode === undefined ? {} : { mode },
383
+ };
231
384
  }
232
385
  // Observe-and-enrich, never veto by itself: delegate first, then fold this
233
386
  // plugin's notice onto whatever came back. `additionalContexts` rides both
@@ -264,17 +417,29 @@ export function apply(ctx, config = {}) {
264
417
  if (agent === undefined || !tracked(exec.name))
265
418
  return next();
266
419
  const state = states.get(agent);
267
- if (!shouldDeny(state, callKey(exec.name, exec.arguments), enforceAfter, maxDenials))
420
+ const key = callKey(exec.name, exec.arguments);
421
+ if (!shouldDeny(state, ENFORCED_FAMILY, key, enforceAfter, maxDenials))
268
422
  return next();
269
423
  if (state === undefined)
270
424
  return next();
425
+ const record = state.families[ENFORCED_FAMILY];
426
+ if (record === undefined)
427
+ return next();
428
+ // `denialText` speaks the ACL family's language (the `icacls` line), so the
429
+ // record is asked to be that family's before it is used: the family tag on
430
+ // a record and the key it is stored under are not the same fact, and this
431
+ // is the one place where confusing them would put the wrong remedy in
432
+ // front of the model.
433
+ const failure = record.last;
434
+ if (failure.family !== 'acl-provisioning')
435
+ return next();
271
436
  // Tag before delegating, and spend the budget immediately: the denial must
272
437
  // be accounted for even if a later listener replaces this decision.
273
438
  ownDenials.add(exec);
274
- states.set(agent, recordDenial(state));
439
+ states.set(agent, recordDenial(state, ENFORCED_FAMILY));
275
440
  return Promise.resolve({
276
441
  kind: 'deny',
277
- reason: denialText(state.last, state.observations, state.denials + 1, maxDenials),
442
+ reason: denialText(failure, record.observations, record.denials + 1, maxDenials),
278
443
  });
279
444
  }
280
445
  catch (error) {