@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/mode.js ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Which sandbox mode a call ran under, and whether that mode confines.
3
+ *
4
+ * The persistent-shell diagnosis is only true under a **confining** mode. The
5
+ * terminal backend hands the shell argv straight through when the resolved mode
6
+ * is `danger-full-access` and confines it otherwise
7
+ * (`packages/terminal/terminal-bash/src/index.ts`:
8
+ * `if (policy.mode === 'danger-full-access') return argv`), so the same
9
+ * `PTY shell exited during startup` under `danger-full-access` is a different
10
+ * story — a broken or missing shell — and this plugin must stay silent about it
11
+ * rather than assert a sandbox cause it cannot support. The report that frames
12
+ * this family (#7638) says the same thing from the other side: its author's
13
+ * three-arm control shows the mode is the discriminator (minimal × confining
14
+ * fails, minimal × `danger-full-access` succeeds, standard × confining
15
+ * succeeds).
16
+ *
17
+ * The answer is taken from `ctx.sandboxPolicy.resolve({ session })` — the same
18
+ * resolver the terminal layer itself calls before spawning, with the same
19
+ * session — so what is quoted in the advisory is the policy that actually
20
+ * governed the failing call, not a guess reconstructed from configuration.
21
+ *
22
+ * ## Why this is a guarded lookup instead of an import
23
+ *
24
+ * `@deepseek-ai/dsh-sandbox-policy` is **optional** in this plugin's world: a
25
+ * composition may simply not mount the service, and this plugin must degrade to
26
+ * silence rather than fail to load. Two consequences shape this module:
27
+ *
28
+ * - A declared peer dependency is a claim about versions, and this package's
29
+ * packaging guard refuses both an import that is not declared and a
30
+ * declaration that is not imported. A *type-only* import would therefore turn
31
+ * an optional integration into a mandatory claim on every line the peer range
32
+ * admits — and a range that admits a line nobody ran is exactly the defect
33
+ * that guard exists to prevent.
34
+ * - What is left is the consumer-side capability guard: look the service up,
35
+ * check the shape of the answer instead of trusting it, and **fail closed**
36
+ * (`{ ok: false }`): an unresolvable mode withholds the advisory, and the
37
+ * caller discloses that withholding on the host side. An unresolvable mode is
38
+ * emphatically *not* an invitation to fall back to the deployment default —
39
+ * a session that overrode its mode to `danger-full-access` would then be
40
+ * diagnosed as if it were confined.
41
+ *
42
+ * The call site this module depends on is stable across every line the peer
43
+ * range claims: `resolve(request?: SandboxPolicyRequest): SandboxExecutionPolicy`
44
+ * is declared at the same position of `lib/types/index.d.ts` in every published
45
+ * build from `0.1.2-rc.1` to `0.1.7-rc.1`, and `Agent.session` is present on
46
+ * the same span of `@deepseek-ai/dsh-agent`.
47
+ *
48
+ * @module
49
+ */
50
+ /**
51
+ * Whether a mode confines the process it is asked to spawn.
52
+ * @param mode - the resolved mode.
53
+ * @returns true for every mode except `danger-full-access`.
54
+ */
55
+ export function confines(mode) {
56
+ return mode !== 'danger-full-access';
57
+ }
58
+ /**
59
+ * Narrow an optional lookup result to the resolver shape.
60
+ * @param value - whatever `ctx.get('sandboxPolicy')` returned.
61
+ * @returns the same object, typed as the resolver, or undefined.
62
+ */
63
+ function asPolicy(value) {
64
+ if (value === null || typeof value !== 'object')
65
+ return undefined;
66
+ if (typeof value.resolve !== 'function')
67
+ return undefined;
68
+ return value;
69
+ }
70
+ /**
71
+ * The agent's live session, read defensively.
72
+ *
73
+ * `Agent.session` is declared by `@deepseek-ai/dsh-agent`'s runtime face and is
74
+ * present on every claimed line; it is read as `unknown` here anyway, because a
75
+ * gate that trusts the shape of its input is the same gate that reports a cause
76
+ * from the wrong world when the input is not what it expected.
77
+ * @param agent - the agent whose call failed.
78
+ * @returns the session object, or undefined.
79
+ */
80
+ function agentSession(agent) {
81
+ const session = agent.session;
82
+ return session !== null && typeof session === 'object' ? session : undefined;
83
+ }
84
+ /**
85
+ * The mode inside a resolved policy, if it is one this harness defines.
86
+ * @param resolved - the resolver's return value.
87
+ * @returns the mode, or undefined when the value is not a recognizable policy.
88
+ */
89
+ function recognizedMode(resolved) {
90
+ if (resolved === null || typeof resolved !== 'object')
91
+ return undefined;
92
+ const mode = resolved.mode;
93
+ return mode === 'read-only' || mode === 'workspace-write' || mode === 'danger-full-access'
94
+ ? mode
95
+ : undefined;
96
+ }
97
+ /**
98
+ * Resolve the effective sandbox mode for one agent's call.
99
+ *
100
+ * The service is looked up through `ctx.get` — the documented optional lookup —
101
+ * and the resolver is invoked with the agent's own session, so a session that
102
+ * logged a `sandbox/mode` override is answered with that override rather than
103
+ * with the deployment default.
104
+ * @param ctx - the plugin's context.
105
+ * @param agent - the agent whose call failed.
106
+ * @returns the mode, or the reason it could not be resolved.
107
+ */
108
+ export function resolveSandboxMode(ctx, agent) {
109
+ const policy = asPolicy(ctx.get('sandboxPolicy'));
110
+ if (policy === undefined) {
111
+ return {
112
+ ok: false,
113
+ withheld: 'no `sandboxPolicy` service is mounted in this composition, so the effective mode is unknown',
114
+ };
115
+ }
116
+ const session = agentSession(agent);
117
+ if (session === undefined) {
118
+ return {
119
+ ok: false,
120
+ withheld: 'the agent exposes no session, and the policy must be resolved from it rather than from the deployment default',
121
+ };
122
+ }
123
+ let resolved;
124
+ try {
125
+ resolved = policy.resolve({ session });
126
+ }
127
+ catch (error) {
128
+ return { ok: false, withheld: `\`sandboxPolicy.resolve\` threw (${String(error)})` };
129
+ }
130
+ const mode = recognizedMode(resolved);
131
+ if (mode === undefined) {
132
+ return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a recognizable mode' };
133
+ }
134
+ return { ok: true, mode };
135
+ }
package/lib/signature.js CHANGED
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Recognize the Windows ACL provisioning failure that has no path forward.
2
+ * Recognize the two environment failures this plugin explains, and refuse
3
+ * everything else.
4
+ *
5
+ * ## The ACL provisioning failure (`acl-provisioning`)
3
6
  *
4
7
  * The harness's Windows sandbox provisions a workspace by writing the
5
8
  * directory's DACL and its mandatory-integrity label in **one**
@@ -10,7 +13,7 @@
10
13
  * it. Every sandboxed command then fails the same way, forever, because the
11
14
  * grant is materialized lazily and nothing is cached on the failure path.
12
15
  *
13
- * Recognizing the string is therefore the whole job of this module, and the
16
+ * Recognizing the string is therefore the whole job of this half, and the
14
17
  * recognition is deliberately narrow:
15
18
  *
16
19
  * - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
@@ -27,8 +30,32 @@
27
30
  * the advisory can quote the exact line the model and the user are looking
28
31
  * at, and the path can be re-used in the fix command.
29
32
  *
33
+ * ## The persistent-shell startup failure (`pty-startup`)
34
+ *
35
+ * `dsh-terminal-bash` throws `PTY shell exited during startup` when the shell
36
+ * it spawned through the sandbox exits before reaching its first prompt
37
+ * (`src/session.ts` and `src/index.ts`, both on the same `waitReason ===
38
+ * 'session_exit'` branch). The text names no cause and, under the `minimal`
39
+ * preset — whose only shell tool is a persistent PTY — there is no other shell
40
+ * tool left to fall back on, so the model reads it as "the command failed" and
41
+ * retries forever. #7638 is the report: 33 consecutive failures under
42
+ * `workspace-write`, none under `danger-full-access`, with the reporter's own
43
+ * three-arm control showing the sandbox mode is the discriminator.
44
+ *
45
+ * This family is recognized on an **exact line**, not on a substring, and that
46
+ * is deliberate. The producer's message has no detail field at all — the whole
47
+ * message is the sentence — so anything that merely *contains* the phrase is
48
+ * quoting it (a transcript, a log a failing command printed, a pasted issue
49
+ * body) rather than producing it. The sibling throw on the same branch, `PTY
50
+ * shell did not reach readiness before startup timeout`, is **not** classified
51
+ * here: it means the shell started and then did not reach a prompt, which is a
52
+ * different cause space (a slow or blocked shell) with a different remedy, and
53
+ * a classifier that names a wrong cause is worse than one that stays silent.
54
+ *
30
55
  * @module
31
56
  */
57
+ /** The exact text `dsh-terminal-bash` throws when the shell exits during startup. */
58
+ export const PTY_STARTUP_EXIT = 'PTY shell exited during startup';
32
59
  /**
33
60
  * The producer's format is fixed by `Win32Error`
34
61
  * (`packages/subprocess/win32-process/src/errors.ts`):
@@ -52,7 +79,7 @@ function splitDetail(detail) {
52
79
  return { label, path };
53
80
  }
54
81
  /**
55
- * Classify one failure message.
82
+ * Classify one failure message against the ACL family.
56
83
  * @param message - the failure text, from the result's `error.message` or its rendered content.
57
84
  * @returns the recognized failure, or undefined when this is not a provisioning failure.
58
85
  */
@@ -68,14 +95,38 @@ export function classifyProvisioningFailure(message) {
68
95
  const klass = api === 'GetNamedSecurityInfoW'
69
96
  ? 'read-denied'
70
97
  : code === ACCESS_DENIED ? 'apply-denied' : 'apply-other';
71
- return { klass, api, win32Code: code, detail, ...splitDetail(detail) };
98
+ return { family: 'acl-provisioning', klass, api, win32Code: code, detail, ...splitDetail(detail) };
99
+ }
100
+ /**
101
+ * Classify one failure message as the persistent-shell startup failure.
102
+ *
103
+ * Recognition is by **whole line**, because the producer's message is a bare
104
+ * sentence with no fields of its own: a line equal to
105
+ * {@link PTY_STARTUP_EXIT} — with only the `Error: ` envelope a tool result adds
106
+ * in front of it — is the producer. A longer line that happens to contain the
107
+ * sentence is something quoting it (a transcript, a log the failing command
108
+ * printed, a pasted issue body), and advising about the sandbox there would be
109
+ * advice about the wrong thing.
110
+ * @param message - the failure text, from the result's `error.message` or its rendered content.
111
+ * @returns the recognized failure, or undefined when this is not one.
112
+ */
113
+ export function classifyPtyStartupFailure(message) {
114
+ for (const raw of message.split('\n')) {
115
+ const line = raw.trim();
116
+ if (line === PTY_STARTUP_EXIT || line === `Error: ${PTY_STARTUP_EXIT}`) {
117
+ return { family: 'pty-startup', line: PTY_STARTUP_EXIT };
118
+ }
119
+ }
120
+ return undefined;
72
121
  }
73
122
  /**
74
123
  * The one-line failure the producer wrote, for quoting back verbatim.
75
124
  * @param failure - a recognized failure.
76
- * @returns the message text a `Win32Error` would have produced.
125
+ * @returns the message text the producing layer would have produced.
77
126
  */
78
127
  export function failureLine(failure) {
128
+ if (failure.family === 'pty-startup')
129
+ return failure.line;
79
130
  const suffix = failure.detail.length === 0 ? '' : `: ${failure.detail}`;
80
131
  return `${failure.api} failed (Win32 ${failure.win32Code})${suffix}`;
81
132
  }
package/lib/state.js CHANGED
@@ -6,21 +6,52 @@
6
6
  * failing call carries `exec.agent`, one agent owns one session, and a WeakMap
7
7
  * keyed by the agent object lets a finished session's state be collected.
8
8
  *
9
- * Two counters, two meanings — keeping them apart is what stops the plugin from
10
- * feeding on itself:
9
+ * ## One record per family
11
10
  *
12
- * - `observations` counts *provisioning failures of this environment*, i.e.
11
+ * This plugin now recognizes two unrelated environment failures — a workspace
12
+ * that cannot be provisioned (`acl-provisioning`) and a persistent shell that
13
+ * cannot start (`pty-startup`). They are different diagnoses with different
14
+ * remedies, so their bookkeeping is kept apart under one agent
15
+ * ({@link AgentState.families}): an agent that hits both is told about both,
16
+ * and an agent that has already been told about one is still told about the
17
+ * other. Sharing one "already advised" flag would silently swallow the second
18
+ * diagnosis, which is the failure mode this split exists to prevent.
19
+ *
20
+ * Three counters, three meanings — keeping them apart is what stops the plugin
21
+ * from feeding on itself:
22
+ *
23
+ * - `observations` counts *failures of this environment in this family*, i.e.
13
24
  * 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.
25
+ * not one of them, even though its denial text quotes the producer's line.
15
26
  * - `failingKeys` holds the call identities (tool + canonical arguments) that
16
27
  * have already failed this way. The fail-fast half may only refuse a call it
17
28
  * 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.
29
+ * - `denials` is spent per *episode*: it is re-armed when a watched call finally
30
+ * succeeds (see `observeSuccess`), not carried for the whole session.
21
31
  *
22
32
  * @module
23
33
  */
34
+ /** The state of an agent this plugin has never observed. */
35
+ export function emptyState() {
36
+ return { families: {}, withheld: false };
37
+ }
38
+ /** The record for one family, if this agent has one. */
39
+ function familyOf(state, family) {
40
+ return state?.families[family];
41
+ }
42
+ /**
43
+ * Replace one family's record, leaving the others (and the withheld flag) alone.
44
+ * @param state - the agent's current state, or undefined on first sight.
45
+ * @param family - the family being updated.
46
+ * @param record - the family's new record.
47
+ * @returns the updated state.
48
+ */
49
+ function withFamily(state, family, record) {
50
+ return {
51
+ families: { ...state?.families, [family]: record },
52
+ withheld: state?.withheld ?? false,
53
+ };
54
+ }
24
55
  /**
25
56
  * Canonicalize a parsed argument value into a stable string.
26
57
  *
@@ -63,46 +94,91 @@ export function callKey(name, args) {
63
94
  return `${name}(${canonicalize(args)})`;
64
95
  }
65
96
  /**
66
- * Record one observed provisioning failure.
97
+ * Record one observed failure in one family.
67
98
  * @param state - the agent's current state, or undefined on first sight.
99
+ * @param family - the family the failure belongs to.
68
100
  * @param failure - the recognized failure.
69
101
  * @param key - the identity of the call that failed.
70
102
  * @returns the updated state.
71
103
  */
72
- export function observe(state, failure, key) {
73
- const failingKeys = new Set(state?.failingKeys ?? []);
104
+ export function observe(state, family, failure, key) {
105
+ const previous = familyOf(state, family);
106
+ const failingKeys = new Set(previous?.failingKeys ?? []);
74
107
  failingKeys.add(key);
75
- return {
76
- observations: (state?.observations ?? 0) + 1,
108
+ return withFamily(state, family, {
109
+ observations: (previous?.observations ?? 0) + 1,
77
110
  last: failure,
78
111
  failingKeys,
79
- denials: state?.denials ?? 0,
80
- advised: state?.advised ?? false,
112
+ denials: previous?.denials ?? 0,
113
+ advised: previous?.advised ?? false,
114
+ });
115
+ }
116
+ /**
117
+ * Whether this family's durable advisory has already been delivered.
118
+ * @param state - the agent's current state, or undefined.
119
+ * @param family - the family in question.
120
+ * @returns true when the advice is already in the session.
121
+ */
122
+ export function advisedOf(state, family) {
123
+ return familyOf(state, family)?.advised ?? false;
124
+ }
125
+ /**
126
+ * Mark one family's durable advisory as delivered.
127
+ * @param state - the agent's current state.
128
+ * @param family - the family that was advised.
129
+ * @returns the updated state.
130
+ */
131
+ export function recordAdvice(state, family) {
132
+ const previous = familyOf(state, family);
133
+ if (previous === undefined)
134
+ return state;
135
+ return withFamily(state, family, { ...previous, advised: true });
136
+ }
137
+ /**
138
+ * Record that a recognized failure was withheld from the model.
139
+ * @param state - the agent's current state, or undefined on first sight.
140
+ * @returns the updated state.
141
+ */
142
+ export function recordWithheld(state) {
143
+ return {
144
+ families: { ...state?.families },
145
+ withheld: true,
81
146
  };
82
147
  }
83
148
  /**
84
149
  * 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.
150
+ * succeeded — in **every** family that was watching that identity.
151
+ *
152
+ * The environment worked at least once for that call, so the entry stops
153
+ * justifying a denial — and is dropped rather than kept, so a later failure
154
+ * re-earns it. A success is evidence about the environment, not about one
155
+ * diagnosis: the same call cannot have started working for one family's reason
156
+ * and not the other's.
88
157
  *
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.
158
+ * Each affected family's denial budget is re-armed at the same moment, and only
159
+ * then. Measured consequence: without it the budget is per agent for the whole
160
+ * session, which makes the entry above unobservable — past `maxDenials` this
161
+ * plugin refuses nothing ever again, so clearing the key would change no
162
+ * decision. With it, the bound reads as "at most `maxDenials` refusals per
163
+ * episode of brokenness": an environment that breaks, is repaired and breaks
164
+ * again may be refused again, while a session can always make progress by
165
+ * spending the budget.
96
166
  * @param state - the agent's current state.
97
167
  * @param key - the identity of the call that just succeeded.
98
- * @returns the updated state, unchanged when the key was not failing.
168
+ * @returns the updated state, unchanged when no family was watching the key.
99
169
  */
100
170
  export function observeSuccess(state, key) {
101
- if (!state.failingKeys.has(key))
171
+ const watched = Object.entries(state.families)
172
+ .filter(([, record]) => record.failingKeys.has(key));
173
+ if (watched.length === 0)
102
174
  return state;
103
- const failingKeys = new Set(state.failingKeys);
104
- failingKeys.delete(key);
105
- return { ...state, failingKeys, denials: 0 };
175
+ let next = state;
176
+ for (const [family, record] of watched) {
177
+ const failingKeys = new Set(record.failingKeys);
178
+ failingKeys.delete(key);
179
+ next = withFamily(next, family, { ...record, failingKeys, denials: 0 });
180
+ }
181
+ return next;
106
182
  }
107
183
  /**
108
184
  * Whether a call may be refused before dispatch.
@@ -112,34 +188,35 @@ export function observeSuccess(state, key) {
112
188
  * fail. The second condition is what keeps the fail-fast half from blocking a
113
189
  * workaround: a different command, or the same command under a different policy
114
190
  * after the user changed configuration, has no key here.
191
+ *
192
+ * Only the `acl-provisioning` family ever reaches this question; see the
193
+ * `denialText` doc for why the blocking half does not extend to `pty-startup`.
115
194
  * @param state - the agent's current state, or undefined.
195
+ * @param family - the family whose threshold is being asked about.
116
196
  * @param key - the identity of the call about to dispatch.
117
197
  * @param enforceAfter - the configured threshold; 0 disables the half entirely.
118
198
  * @param maxDenials - the configured denial budget.
119
199
  * @returns whether to deny.
120
200
  */
121
- export function shouldDeny(state, key, enforceAfter, maxDenials) {
122
- if (enforceAfter === 0 || state === undefined)
201
+ export function shouldDeny(state, family, key, enforceAfter, maxDenials) {
202
+ const record = familyOf(state, family);
203
+ if (enforceAfter === 0 || record === undefined)
123
204
  return false;
124
- if (state.observations < enforceAfter)
205
+ if (record.observations < enforceAfter)
125
206
  return false;
126
- if (state.denials >= maxDenials)
207
+ if (record.denials >= maxDenials)
127
208
  return false;
128
- return state.failingKeys.has(key);
209
+ return record.failingKeys.has(key);
129
210
  }
130
211
  /**
131
212
  * Spend one denial.
132
213
  * @param state - the agent's current state.
214
+ * @param family - the family being denied.
133
215
  * @returns the updated state.
134
216
  */
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 };
217
+ export function recordDenial(state, family) {
218
+ const previous = familyOf(state, family);
219
+ if (previous === undefined)
220
+ return state;
221
+ return withFamily(state, family, { ...previous, denials: previous.denials + 1 });
145
222
  }
@@ -1,38 +1,112 @@
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:
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.
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:
8
+ *
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
- import type { ProvisioningFailure } from './signature.js';
23
- /** The upstream threads this advisory is a stopgap for. */
24
- export declare const DISCUSSIONS = "#7538 / #7622 / #7646";
34
+ import type { ProvisioningFailure, RecognizedFailure } from './signature.js';
35
+ import type { SandboxModeName } from './mode.js';
36
+ /** The upstream threads the ACL advisory is a stopgap for. */
37
+ export declare const ACL_DISCUSSIONS = "#7538 / #7622 / #7646 / #7720";
38
+ /** The upstream thread the persistent-shell advisory is a stopgap for. */
39
+ export declare const PTY_DISCUSSIONS = "#7638";
25
40
  /** The documented prerequisite, quoted from the backend's README. */
26
41
  export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
42
+ /**
43
+ * Where a user's own preset changes actually live.
44
+ *
45
+ * This is deliberately **not** the legacy `$DSH_HOME/.agent-presets/<id>/`
46
+ * directory: that shape predates declarative presets and **nothing reads it any
47
+ * more** (the registry "neither scans directories nor accepts preset paths").
48
+ * A preset is a `@deepseek-ai/dsh-agent-preset` row, and changing one means
49
+ * overriding or inserting that row in a patch layer, which is what this path
50
+ * names. Advising a folder the harness stopped reading would be the same defect
51
+ * this plugin exists to answer — a remedy that does not work, delivered
52
+ * confidently.
53
+ */
54
+ export declare const PROFILE_PATCH = "$DSH_HOME/profiles/<profile>/cordis.patch.yml";
55
+ /** The machine-wide patch layer, for a change that should hold in every profile. */
56
+ export declare const GLOBAL_PATCH = "$DSH_HOME/cordis.patch.yml";
57
+ /** The row id the shipped `minimal` preset is declared under. */
58
+ export declare const MINIMAL_PRESET_ROW = "preset-minimal";
59
+ /** The one-shot shell tool the `standard` preset mounts on Windows. */
60
+ export declare const ONE_SHOT_SHELL = "@deepseek-ai/dsh-tool-pwsh";
61
+ /**
62
+ * The two remedies that look like the fix and are not.
63
+ *
64
+ * Both were applied by the reporter of `#7720` before finding the one that
65
+ * works, and both are the *natural* reach: making yourself the owner and
66
+ * resetting the directory's ACL are how one normally repairs a Windows
67
+ * permission problem. They fail here for two different reasons, and naming the
68
+ * reason is what makes this section worth its lines — a reader who already
69
+ * tried them learns why, and a reader who has not is spared the attempt. See
70
+ * {@link nonFixes} for when this is emitted.
71
+ */
72
+ export declare const NOT_FIXES: readonly ["takeown /F \"<dir>\" /R /D Y", "icacls \"<dir>\" /reset /T /C"];
73
+ /** What the caller knows about the failing call, beyond the failure text. */
74
+ export interface AdvisoryContext {
75
+ /** URL quoted in place of the discussions list; optional. */
76
+ readonly href?: string;
77
+ /** The tool whose call failed, quoted back so the advice is about that call. */
78
+ readonly tool?: string;
79
+ /**
80
+ * The sandbox mode the failing call ran under. Required by the
81
+ * `pty-startup` family — the whole diagnosis is the mode — and unused by the
82
+ * ACL family.
83
+ */
84
+ readonly mode?: SandboxModeName;
85
+ }
27
86
  /**
28
87
  * Build the advisory attached to the failing tool result.
88
+ *
89
+ * The family decides everything: one function so a caller does not have to
90
+ * remember which family needs which fact, and so the mode requirement of the
91
+ * PTY family is enforced by construction rather than by convention.
29
92
  * @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.
93
+ * @param context - what the caller knows about the failing call.
94
+ * @returns the user-role notice text, with any remedy ready to paste.
95
+ * @throws when a PTY failure is advised without its resolved sandbox mode.
32
96
  */
33
- export declare function advisoryText(failure: ProvisioningFailure, href?: string): string;
97
+ export declare function advisoryText(failure: RecognizedFailure, context?: AdvisoryContext): string;
34
98
  /**
35
99
  * Build the pre-dispatch denial for the optional fail-fast half.
100
+ *
101
+ * The blocking half is deliberately **ACL-only**, and this function's parameter
102
+ * type is where that is enforced. The PTY family gets an advisory and nothing
103
+ * else, for a reason that is about the remedy rather than about the failure:
104
+ * the ACL remedy is a command the user can run *while the session continues*,
105
+ * so refusing further identical calls cannot make the session unfinishable —
106
+ * spending the budget always lets the call through, and a repaired environment
107
+ * is discovered by exactly that. The PTY remedy is a preset swap, which happens
108
+ * between sessions; refusing calls could only pad a session that is already
109
+ * unable to do the thing being refused.
36
110
  * @param failure - the recognized failure.
37
111
  * @param observed - how many provisioning failures this agent has produced.
38
112
  * @param denial - this denial's 1-based ordinal.