@argszero/cordis-plugin-sandbox-grant-advisor 0.10.0 → 0.12.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
@@ -3,7 +3,7 @@
3
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, one family at a time. The three families are shaped by the same
6
+ * by a test, one family at a time. The four families are shaped by the same
7
7
  * question — is this the sandbox's doing, and what can the reader do about it —
8
8
  * and they answer it differently:
9
9
  *
@@ -112,6 +112,31 @@
112
112
  * want of that variable. A withdrawn cause earns its sentence because this
113
113
  * advisory shipped it twice; a confidently wrong cause is worse than two named
114
114
  * ones with one shared remedy.
115
+ * - **The workspace-internal denial** (`workspace-denial`, #423) is the fourth,
116
+ * and it is the first whose remedy is **withheld on purpose**. The failure is
117
+ * not that a command failed but that the workspace's own grant does not cover
118
+ * part of the workspace: the grant is written once on the root and depends on
119
+ * ACE inheritance, so an already-existing object whose DACL the caller could
120
+ * not write kept its older DACL, and the backend's root-only `hasExactGrant`
121
+ * check means it is never revisited. Retrying is therefore provably useless,
122
+ * which is what the text leads with. What it does **not** do is print a repair
123
+ * command: the obvious one (`icacls` recursing the capability SID) is refused
124
+ * by Windows itself with `ERROR_NONE_MAPPED` (1332), because that SID has no
125
+ * name to map, and the line that would work needs the right that is missing —
126
+ * so the advisory states the mechanism, names the ceiling, and says out loud
127
+ * that it prints no command because this project has no Windows host to verify
128
+ * one on. That is the same standard the standing-grant section is held to, and
129
+ * it is the reason this family can be useful without being prescriptive.
130
+ * Its discriminator is the **two-sided** one, which is not obvious and which a
131
+ * reader checking only the ACE would get wrong: reading and listing use the
132
+ * normal token while writing and deleting use the restricted one, so an object
133
+ * whose DACL names only `Administrators`/`SYSTEM` plus the capability SID is
134
+ * refused on the read side too and looks granted to a grep for the SID
135
+ * (#423 measured it). The variant that would otherwise be missed gets its own
136
+ * sentence as well — the same shape with the missing coverage on the mandatory
137
+ * label instead of the DACL (reported 2026-09-29 in the same thread), which is
138
+ * indistinguishable from inside a session and separable by the repository's own
139
+ * diagnosis skill.
115
140
  *
116
141
  * Both give a **discriminator, not just a remedy**: applying a fix without
117
142
  * confirming the cause teaches nothing when the fix does not work. For the ACL
@@ -126,6 +151,12 @@
126
151
  * wrong remedy. Inside that Electron answer there is a third thing the code
127
152
  * cannot separate, and the advisory deliberately does not try: it names both
128
153
  * measurements and says the remedy does not depend on choosing between them.
154
+ * For the workspace-denial family the discriminator is the **path**, which is
155
+ * why the advisory prints the path it keyed on beside the root it tested it
156
+ * against: the reader can audit the plugin's own reasoning instead of taking a
157
+ * claim about two strings on faith. That family's check is also the one place
158
+ * where a single fact is not enough — reachability has two sides, and the text
159
+ * says which one a check on the ACE alone would miss.
129
160
  *
130
161
  * @module
131
162
  */
@@ -158,7 +189,34 @@ export const PTY_DISCUSSIONS = '#7638 / #8322';
158
189
  * launched from a terminal does not, which is the same variable the PTY family
159
190
  * now names.
160
191
  */
161
- export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193 / #8208 / #8313';
192
+ export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193 / #8208 / #8313 / #8336 / #8334';
193
+ /**
194
+ * The upstream thread the workspace-denial advisory is a stopgap for.
195
+ *
196
+ * One thread, because this family is one report and its own follow-up: `#423`
197
+ * is the failure and its measurements (170 of 729 objects missing the grant,
198
+ * root-level files among them, and the two-sided reachability rule), and the
199
+ * second comment in that thread is the mandatory-label variant of the same
200
+ * shape.
201
+ */
202
+ export const WORKSPACE_DENIAL_DISCUSSIONS = '#423';
203
+ /**
204
+ * The thread list each family's withholding note cites.
205
+ *
206
+ * A withheld recognition is a decision the host log has to account for, and the
207
+ * note a maintainer reads is only useful if it points at *that* family's
208
+ * report — a withheld native-init death and a withheld workspace denial are
209
+ * different reports, and a note that cites the wrong one is its own small
210
+ * misdiagnosis. Kept beside the constants it assembles rather than at the
211
+ * withholding site, so a family added without a thread is a type error instead
212
+ * of a note that quietly cites another family's.
213
+ */
214
+ export const DISCUSSIONS_OF = {
215
+ 'acl-provisioning': ACL_DISCUSSIONS,
216
+ 'pty-startup': PTY_DISCUSSIONS,
217
+ 'native-init': NATIVE_INIT_DISCUSSIONS,
218
+ 'workspace-denial': WORKSPACE_DENIAL_DISCUSSIONS,
219
+ };
162
220
  /** The documented prerequisite, quoted from the backend's README. */
163
221
  export const PREREQUISITE = 'granted directories must be caller-owned and grant `WRITE_OWNER`';
164
222
  /**
@@ -448,6 +506,8 @@ export function advisoryText(failure, context = {}) {
448
506
  }
449
507
  return nativeInitAdvisory(failure, context.mode, context.electronHost ?? electronHost(), context.href);
450
508
  }
509
+ if (failure.family === 'workspace-denial')
510
+ return workspaceDenialAdvisory(failure, context.tool, context.href);
451
511
  return aclAdvisory(failure, context.href);
452
512
  }
453
513
  /**
@@ -622,12 +682,31 @@ function nativeInitAdvisory(failure, mode, onElectron, href) {
622
682
  'Honest boundary — 0xC0000142 has producers this list does not have: a program that cannot load one of',
623
683
  'its own DLLs dies this way too, and the backend\'s own source records the console case as an inherent',
624
684
  'limit of the backend (`CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` children die with `STATUS_DLL_INIT_FAILED`',
625
- 'under the restriction). #8208 explains that limit instead of repeating it, and the explanation is what the',
626
- 'two shapes above share: in a restricted token a console can be INHERITED but not CREATED. The creation',
627
- 'flags actually passed are three sets and none of them is `CREATE_NO_WINDOW` — `0` on the piped path,',
628
- '`CREATE_SUSPENDED` on the inherited-job path, and `CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT` on the',
629
- 'ordinary path. Those same flags are fatal under a console-less runner and harmless under one that owns a',
630
- 'console, so it is the console and not the flag list that decides.',
685
+ 'under the restriction). #8208 explains that limit instead of repeating it, and the explanation is a single',
686
+ 'rule: in a restricted token a console can be INHERITED but not CREATED. Both shapes above follow from it —',
687
+ 'the child needs a console it did not create, and anything that forces it to CREATE one is fatal on its own.',
688
+ '#8336 measured that side directly, on one machine, with a console-owning host, a restricted token and the',
689
+ 'Low integrity level, varying only the creation flags: `0`, `DETACHED_PROCESS` and `CREATE_NEW_PROCESS_GROUP`',
690
+ 'all reached the program, while `CREATE_NO_WINDOW` and `CREATE_NEW_CONSOLE` both died with the code above.',
691
+ '`STARTF_USESHOWWINDOW` with `SW_HIDE` — how a window is hidden without isolating a console — was harmless,',
692
+ 'and so was `CREATE_NO_WINDOW` on an UNRESTRICTED token, which is what makes the two a pair rather than a',
693
+ 'list of forbidden flags.',
694
+ 'The creation flags this harness actually passes are three sets and none of them is `CREATE_NO_WINDOW` —',
695
+ '`0` on the piped path, `CREATE_SUSPENDED` on the inherited-job path, and',
696
+ '`CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT` on the ordinary path (that constant is not defined anywhere',
697
+ 'in the process source). Those three are fatal under a console-less host and harmless under a host that owns',
698
+ 'a console, so there the console decides. A flag that forces creation is a different animal: it decides even',
699
+ 'when a console is there to be inherited.',
700
+ '',
701
+ 'One thing that gets suspected and is not the cause: `windowsHide`. It is named here because it is the',
702
+ 'first thing a search turns up, and it points at the wrong component — the flag is not set anywhere on the',
703
+ 'confined path above. It appears on the ORDINARY subprocess path (`dsh-subprocess-local`: `windowsHide:`',
704
+ '`platform === \'win32\'`), which starts the runner rather than the confined child, and the restricted spawn',
705
+ 'in `dsh-win32-process` passes the three flag sets just listed and defines no `CREATE_NO_WINDOW` constant at',
706
+ 'all. Where it IS set — on the host — #8208 measured it both ways on a host that works: with and without',
707
+ '`windowsHide`, the confined `pwsh` reached exit 0 with its own stdout intact. The reason is the rule again:',
708
+ 'a windowless console is still a console, and that is what the child inherits. What decides is whether the',
709
+ 'host owns a console OBJECT, not whether it owns a window.',
631
710
  '',
632
711
  'One arm of this family was applied, measured, and rejected — named so a reader does not reach for it:',
633
712
  'putting `DETACHED_PROCESS` on the RESTRICTED CHILD removes its console request and does stop the crash,',
@@ -713,6 +792,117 @@ function ptyAdvisory(failure, mode, tool, href) {
713
792
  'shell can fail to start for other reasons and a confident wrong cause is worse than no answer.',
714
793
  ].join('\n');
715
794
  }
795
+ /**
796
+ * Build the advisory for a confined command that was denied a path inside its
797
+ * own workspace.
798
+ *
799
+ * Four things this text must do, and one it must not. It must lead with the
800
+ * fact that **retrying is provably useless** (the backend's provisioning check
801
+ * short-circuits on the root, so the object that missed the propagation is never
802
+ * revisited) — that is the whole reason the model needs telling rather than
803
+ * discovering. It must show the **path it keyed on and the root it tested it
804
+ * against**, because the family's claim is a statement about two strings and the
805
+ * reader is entitled to audit it. It must give the **two-sided** reachability
806
+ * rule, since a check that only looks for the capability ACE reports an object
807
+ * as granted when the read side is refused as well. And it must name the
808
+ * **label variant** of the same shape, because from inside a session the two are
809
+ * indistinguishable and a reader who repairs the wrong half has learned
810
+ * nothing.
811
+ *
812
+ * What it must not do is print a repair command. The obvious one is refused by
813
+ * Windows with `ERROR_NONE_MAPPED` (1332) — a capability SID has no name for
814
+ * `icacls` to map — and the one that would work needs `WRITE_DAC` on the object,
815
+ * which is the right in question. This project has no Windows host to verify a
816
+ * line on, and the standing-grant section of the ACL advisory is held to the
817
+ * same standard for the same reason: an unverified remedy delivered confidently
818
+ * is the defect this plugin exists to answer. The mechanism is stated instead,
819
+ * and the reader is told why the command is absent.
820
+ * @param failure - the recognized failure.
821
+ * @param tool - the tool whose call was denied, when the caller knows it.
822
+ * @param href - optional URL shown for the upstream thread.
823
+ * @returns the user-role notice text.
824
+ */
825
+ function workspaceDenialAdvisory(failure, tool, href) {
826
+ const where = href === undefined
827
+ ? `tracked upstream (discussion ${WORKSPACE_DENIAL_DISCUSSIONS})`
828
+ : `tracked upstream: ${href}`;
829
+ const call = tool === undefined ? 'This command' : `The \`${tool}\` command`;
830
+ const subject = failure.paths[0] ?? '<the path from the error line above>';
831
+ return [
832
+ 'Denied inside your own workspace — the workspace grant does not reach that part of the tree, and retrying cannot repair it.',
833
+ '',
834
+ 'What was reported:',
835
+ ` ${failureLine(failure)}`,
836
+ `${call} ran under sandbox mode \`${failure.mode}\`, where a write INSIDE the workspace is supposed to succeed, and every`,
837
+ 'path it named is inside this session\'s workspace:',
838
+ ...failure.paths.map(path => ` ${path}`),
839
+ ` (workspace root: ${failure.workspaceRoot})`,
840
+ 'So this is not the sandbox declining work that belongs outside the workspace. It is the workspace\'s own grant',
841
+ 'failing to cover an object inside it, which is why every command touching that object fails the same way.',
842
+ '',
843
+ 'Why — and why retrying cannot fix it:',
844
+ ' The host-side grant is written ONCE, on the workspace ROOT, and relies on Windows ACE inheritance to reach',
845
+ ' everything beneath it. Writing an inherited ACE into an ALREADY-EXISTING child needs WRITE_DAC on that child;',
846
+ ' where the caller does not hold it, Windows skips the child silently — no error, no return value, no log line.',
847
+ ' The backend then checks only the root (`hasExactGrant(workspaceRoot)`) and returns early when the grant is',
848
+ ' already there, which it is from the first call onwards. The descendants that missed the propagation are',
849
+ ' therefore never revisited — not later in this session, not in any later one — so the identical command keeps',
850
+ ' failing and there is no number of attempts that changes that',
851
+ ' (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).',
852
+ ' WHICH objects miss it is a fact about who created them: objects the harness itself creates inherit the ACE,',
853
+ ' while objects that already existed or that another account or tool created (an installer, an editor, another',
854
+ ' agent harness running under its own account) are the ones the propagation skipped. #423 measured 170 of 729',
855
+ ' objects missing it, INCLUDING root-level files — so it is not only subdirectories, and "write it at the',
856
+ ' workspace root instead" is not a safe move either.',
857
+ '',
858
+ 'Confirm it — this is the discriminator, and the second half is the part a single check gets wrong:',
859
+ ` icacls "${subject}"`,
860
+ 'compared with the same command against the workspace root. The root carries an inheritable ACE for the workspace',
861
+ 'capability SID — a `S-1-4-…` that `icacls` prints as an unresolved SID rather than a name — with Modify or Full',
862
+ 'control; the failing object does not have it. Check each path listed above the same way; the first is shown here.',
863
+ ' THE TRAP: reading and listing go through the NORMAL token, writing and deleting through the RESTRICTED',
864
+ ' (low-integrity) one, and both sides must pass. An object whose DACL names only Administrators/SYSTEM plus the',
865
+ ' capability SID is refused on the read side as well, so it looks granted to a check that only searches for the',
866
+ ' capability SID — #423 measured exactly that on a `.cache` directory. A check that asks only "is the SID there?"',
867
+ ' reports those objects as fine while LIST and WRITE are both denied.',
868
+ ' A SECOND measured variant has the same shape and a different object: the coverage that is missing can be the',
869
+ ' mandatory-integrity LABEL rather than a DACL entry. It was reported in this same thread on 2026-09-29 against a',
870
+ ' `0.2.0-rc.1` install, under the same root-only short-circuit. Inside a session the two are indistinguishable;',
871
+ ' from outside, the repository\'s own diagnosis skill separates them — `diagnose-windows-sandbox-acl` (0.2.0 and',
872
+ ' later) reports `hasExactDeny()` for the DACL half and `LOW_LABEL` (`S-1-16-4096`) for the label half.',
873
+ '',
874
+ 'Why the natural repair is closed — this is a Windows ceiling, not a mistake in the command:',
875
+ ' The grant the root carries is written for a capability SID, and a recursive `icacls /grant "*S-1-4-…:…" /T /C`',
876
+ ' is refused with ERROR_NONE_MAPPED (1332): the tool cannot map that SID to a name, so a grant that needs to name',
877
+ ' it never reaches the child. Widening the grant to a nameable account is a different grant with different',
878
+ ' consequences, not this one restored.',
879
+ '',
880
+ 'What helps:',
881
+ ' - The move that is yours, and the only one available inside the session: write new files under a directory the',
882
+ ' harness itself created in this workspace. Those carry the grant, because their inheritance did apply — the',
883
+ ' reporter\'s own measurement, that objects DSH created are complete here and externally created ones are not.',
884
+ ' - The user\'s move: repair the specific object from an account that already holds WRITE_DAC on it, by writing',
885
+ ' the ACE the root carries into the child\'s own DACL. That needs the very right that is missing, so it is not',
886
+ ' something an unelevated prompt can do.',
887
+ ' No repair command is printed here on purpose. This project has no Windows host to verify one on, and shipping',
888
+ ' an unverified line would be the same defect this plugin exists to answer. The mechanism above is what is known;',
889
+ ' the line is yours to choose.',
890
+ '',
891
+ 'The retry you are about to be offered: the denial surface offers one retry of this exact command under a wider',
892
+ 'mode. That retry can only succeed by removing the confinement itself — it repairs nothing in the workspace, and',
893
+ 'the next session meets the same gap. Take it only if that is what you mean to buy.',
894
+ '',
895
+ 'Do NOT retry this call unchanged: the provisioning path short-circuits on the root grant, so the object that was',
896
+ 'skipped is never revisited.',
897
+ '',
898
+ 'How to read this: the denial names no cause and points at no repair, so the diagnosis is delivered here instead.',
899
+ 'This is a stopgap, ' + where + '. This plugin speaks only when the mode is `workspace-write`, the host is',
900
+ 'Windows, and every path the command names lies inside the workspace — a denial anywhere else is the sandbox',
901
+ 'working as designed, and a confident wrong cause is worse than no answer.',
902
+ 'What it is NOT: this plugin does not edit an ACL, does not elevate, and does not offer `danger-full-access` as a',
903
+ 'fix.',
904
+ ].join('\n');
905
+ }
716
906
  /**
717
907
  * Build the pre-dispatch denial for the optional fail-fast half.
718
908
  *
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * `sandbox-grant-advisor`: turn an environment failure that has no path forward
3
3
  * into a diagnosis the model — and the user reading the transcript — can act on.
4
4
  *
5
- * ## The three failures it recognizes
5
+ * ## The four failures it recognizes
6
6
  *
7
7
  * **Workspace provisioning (Windows ACL).** Four reports of one signature
8
8
  * (`#7538`, `#7622`, `#7646`, `#7720`) describe the same shape: the host-side write grant
@@ -68,6 +68,26 @@
68
68
  * tool's own canonical output, never a line of rendered text — and which three
69
69
  * narrowings keep the recognition from firing on something else.
70
70
  *
71
+ * **Denied inside the workspace (Windows ACL, `#423`).** The fourth family is
72
+ * the other half of the backend the first one explains, and it is the first
73
+ * whose remedy is withheld on purpose. There the grant could not be applied at
74
+ * all; here it *was* applied — on the workspace root, once — and Windows ACE
75
+ * inheritance silently skipped objects whose DACL the caller could not write.
76
+ * The backend's provisioning check short-circuits on the root (`hasExactGrant`
77
+ * returns early once the root carries the ACE, `sandbox-windows-acl/src/acl.ts`),
78
+ * so those descendants are never revisited and the same command fails forever,
79
+ * while a directory the harness itself created works. It arrives at the same
80
+ * seam as the native-init family and for the same structural reason: a denied
81
+ * command exits nonzero and the shipped shell tools report that as a finished
82
+ * run, so the fact is in `ToolExecutionSuccess.value` rather than in an error.
83
+ * Unlike that family it needs no bespoke code — the executors stamp the denial
84
+ * onto the value as a structured triple (`sandbox: { mode, denied, enforcement? }`,
85
+ * produced by matching the backend's own refusal dialect in the captured
86
+ * stderr), so this plugin reads the harness's own reading of its own sandbox.
87
+ * See `src/signature.ts` for the facts that narrow it, and in particular for
88
+ * why a denial is the *designed* outcome in three other situations (outside the
89
+ * workspace, under `read-only`, a runner failure) and must never be advised.
90
+ *
71
91
  * ## Where it acts, and why there
72
92
  *
73
93
  * One listener on the public `tools/post-execute` waterfall
@@ -107,9 +127,15 @@
107
127
  * packaged desktop binary, which the plugin **measures and reports** rather
108
128
  * than assumes), and carries the one conversion a model can actually make —
109
129
  * rewrite the work as PowerShell or `cmd` when the program that could not
110
- * start was an MSYS2 one. All three ride `additionalContexts`, so the model
111
- * sees the diagnosis beside the failure rather than only in a log it never
112
- * reads.
130
+ * start was an MSYS2 one. For the workspace-denial family it prints the path
131
+ * it keyed on beside the root it tested it against, says that retrying is
132
+ * provably useless (the root-only provisioning check never revisits the
133
+ * object), gives the two-sided reachability rule, names the label variant of
134
+ * the same shape, and prints **no** repair command — the obvious one is
135
+ * refused by Windows with `ERROR_NONE_MAPPED (1332)`, and this project has no
136
+ * Windows host to verify a line on. All four ride `additionalContexts`, so the
137
+ * model sees the diagnosis beside the failure rather than only in a log it
138
+ * never reads.
113
139
  * 2. **A bounded fail-fast, ACL family only.** With `enforceAfter` set, a call
114
140
  * this plugin has *watched fail* this way is refused at `tools/pre-execute`
115
141
  * once the environment has failed at least that many times. It is off by
@@ -121,8 +147,13 @@
121
147
  * only sent when the resolved mode actually confines; if the mode is not
122
148
  * confining, or cannot be
123
149
  * resolved at all, the failure is left exactly as it was **and the host log
124
- * says so once**. Silence alone would make "the sandbox is not the cause" and
125
- * "this plugin could not tell" indistinguishable from the outside.
150
+ * says so once**. The workspace-denial advisory is withheld the same way when
151
+ * the workspace root — the fact the containment claim is tested against —
152
+ * cannot be resolved. Silence alone would make "the sandbox is not the cause" and
153
+ * "this plugin could not tell" indistinguishable from the outside. A denial
154
+ * the classifier *can* place outside the workspace is a different case and is
155
+ * left silent on purpose: that is the sanctioned escalation path, not a
156
+ * puzzle, and a note about it would be noise.
126
157
  *
127
158
  * ## Honest boundaries
128
159
  *
@@ -139,7 +170,10 @@
139
170
  * tools' own foreground projection with the reported codes, including the
140
171
  * signed form the reporter saw (`-1073741502`) and the real MSYS2 stderr, so the
141
172
  * recognition runs against the producer's data rather than against a message
142
- * this plugin invented.
173
+ * this plugin invented. The workspace-denial family is exercised the same way:
174
+ * the test builds the executors' own `sandbox` stamp and the shipped foreground
175
+ * projection, supplies `win32` as the platform fact, and covers both the
176
+ * recognized case and each of the narrowings that must stay silent.
143
177
  * - **It does not repair anything.** No ACL is written, no privilege is
144
178
  * requested, nothing is elevated, no environment variable is set for another
145
179
  * process, no preset is installed and no mode is changed: the remedies are the
@@ -148,7 +182,7 @@
148
182
  * guard keys on *call identity* (identical arguments retried); this one keys
149
183
  * on the *environment signature*, which is how several different commands can
150
184
  * share one cause. They can be mounted together.
151
- * - **The real fix is upstream**, in all three families: the ACL failure should
185
+ * - **The real fix is upstream**, in all four families: the ACL failure should
152
186
  * name
153
187
  * the outstanding condition at the site that knows it (`grantWrite` computes
154
188
  * `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
@@ -156,14 +190,19 @@
156
190
  * incompatible with the PTY backend" or fall back to a one-shot shell, and the
157
191
  * sandbox runner should be launched with the environment its own execution
158
192
  * needs (`ELECTRON_RUN_AS_NODE` when argv[0] is an Electron binary) or with a
159
- * documented, checkable refusal for MSYS2 programs. This plugin is the stopgap.
193
+ * documented, checkable refusal for MSYS2 programs. The workspace-denial family
194
+ * is the same shape once more: `grantWrite` returns early when the *root*
195
+ * already carries the ACE, so the descendants that missed the propagation are
196
+ * never repaired — the check would have to look past the root, or the denial
197
+ * surface would have to say *which* path was refused instead of only that one
198
+ * was. This plugin is the stopgap.
160
199
  *
161
200
  * @module @argszero/cordis-plugin-sandbox-grant-advisor
162
201
  */
163
202
  import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm';
164
- import { advisoryText, ACL_DISCUSSIONS, denialText, NATIVE_INIT_DISCUSSIONS, PTY_DISCUSSIONS } from './advice.js';
165
- import { classifyNativeInitDeath, classifyProvisioningFailure, classifyPtyStartupFailure } from './signature.js';
166
- import { confines, resolveSandboxMode } from './mode.js';
203
+ import { advisoryText, ACL_DISCUSSIONS, denialText, DISCUSSIONS_OF, NATIVE_INIT_DISCUSSIONS, PTY_DISCUSSIONS, WORKSPACE_DENIAL_DISCUSSIONS } from './advice.js';
204
+ import { classifyNativeInitDeath, classifyProvisioningFailure, classifyPtyStartupFailure, classifyWorkspaceDenial, hasWorkspaceDenialStamp, } from './signature.js';
205
+ import { confines, resolveSandboxMode, resolveWorkspaceRoot } from './mode.js';
167
206
  import { advisedOf, callKey, observe, observeSuccess, recordAdvice, recordDenial, recordWithheld, shouldDeny, } from './state.js';
168
207
  export const name = 'sandbox-grant-advisor';
169
208
  /** The tool pipeline this plugin observes and (optionally) gates. */
@@ -303,6 +342,10 @@ function summaryOf(failure, mode) {
303
342
  return `sandboxed command never started (exit ${String(failure.rawExitCode)}, STATUS_DLL_INIT_FAILED) `
304
343
  + `under sandbox mode "${String(mode)}"`;
305
344
  }
345
+ if (failure.family === 'workspace-denial') {
346
+ const subject = failure.paths[0] ?? 'a path in the workspace';
347
+ return `denied inside the workspace (${subject}) under sandbox mode "${failure.mode}"`;
348
+ }
306
349
  return `workspace ACL provisioning failed (Win32 ${String(failure.win32Code)})`;
307
350
  }
308
351
  /**
@@ -346,16 +389,16 @@ export function apply(ctx, config = {}) {
346
389
  * @param agent - the agent whose failure was withheld.
347
390
  * @param state - the agent's state, to keep the note to one.
348
391
  * @param why - what stopped the advisory.
349
- * @param failure - the recognized failure that was withheld; only the two
350
- * mode-gated families reach this function, so the thread it cites is exact.
392
+ * @param family - the recognized family that was withheld. Only families whose
393
+ * gate can fail closed reach this function, so the thread it cites is exact.
351
394
  * @returns undefined, so callers can `return withhold(...)`.
352
395
  */
353
- function withhold(agent, state, why, failure) {
396
+ function withhold(agent, state, why, family) {
354
397
  if (state?.withheld === true)
355
398
  return undefined;
356
399
  states.set(agent, recordWithheld(state));
357
- const discussions = failure.family === 'pty-startup' ? PTY_DISCUSSIONS : NATIVE_INIT_DISCUSSIONS;
358
- ctx.logger.warn(`sandbox-grant-advisor: ${failure.family} failure recognized but no advisory sent — ${why}; the raw `
400
+ const discussions = DISCUSSIONS_OF[family];
401
+ ctx.logger.warn(`sandbox-grant-advisor: ${family} failure recognized but no advisory sent — ${why}; the raw `
359
402
  + `error is left exactly as it is, so this is NOT a claim that the sandbox is unrelated (discussions ${discussions})`);
360
403
  return undefined;
361
404
  }
@@ -383,12 +426,20 @@ export function apply(ctx, config = {}) {
383
426
  // read from `result.value` and never from the rendered text, so a command
384
427
  // whose own output mentions the code cannot be mistaken for it.
385
428
  const death = classifyNativeInitDeath(result.value);
386
- if (death === undefined) {
387
- if (previous !== undefined)
388
- states.set(agent, observeSuccess(previous, key));
389
- return undefined;
429
+ if (death !== undefined)
430
+ return adviseGated(agent, previous, death, key, exec.name);
431
+ // The workspace-denial family is the other value-read one, and it is read
432
+ // only when the host is Windows: the mechanism it explains is ACE
433
+ // inheritance, which no other backend has, so on macOS/Linux the same
434
+ // stamp is a different story and is left alone with no note at all (that
435
+ // is the designed denial path there, not a puzzle). The platform test
436
+ // comes first so a non-Windows host never pays for the policy lookup.
437
+ if (process.platform === 'win32' && hasWorkspaceDenialStamp(result.value)) {
438
+ return adviseWorkspaceDenial(agent, previous, result.value, exec.arguments, key, exec.name);
390
439
  }
391
- return adviseGated(agent, previous, death, key, exec.name);
440
+ if (previous !== undefined)
441
+ states.set(agent, observeSuccess(previous, key));
442
+ return undefined;
392
443
  }
393
444
  // The two text families read different fields, on purpose. The ACL signature
394
445
  // carries an API name plus a Win32 code, which a command's own output does
@@ -441,12 +492,12 @@ export function apply(ctx, config = {}) {
441
492
  function adviseGated(agent, previous, failure, key, tool) {
442
493
  const resolution = resolveSandboxMode(ctx, agent);
443
494
  if (!resolution.ok)
444
- return withhold(agent, previous, resolution.withheld, failure);
495
+ return withhold(agent, previous, resolution.withheld, failure.family);
445
496
  const mode = resolution.mode;
446
497
  if (!confines(mode)) {
447
498
  const what = failure.family === 'pty-startup' ? 'the shell' : 'the command';
448
499
  return withhold(agent, previous, `the failing call ran under \`${mode}\`, where ${what} is not spawned `
449
- + 'through the sandbox', failure);
500
+ + 'through the sandbox', failure.family);
450
501
  }
451
502
  if (!claimAdvice(agent, previous, failure, key))
452
503
  return undefined;
@@ -468,6 +519,59 @@ export function apply(ctx, config = {}) {
468
519
  states.set(agent, first ? recordAdvice(advanced, failure.family) : advanced);
469
520
  return first;
470
521
  }
522
+ /**
523
+ * Diagnose one denial whose target lies **inside** the agent's own workspace.
524
+ *
525
+ * The fourth family reads the executor's structured stamp rather than a
526
+ * message, so what is left here is the one fact the stamp cannot carry: the
527
+ * workspace root, which the containment claim has to be tested against. It is
528
+ * resolved from the agent's own session (the same resolver the enforcing
529
+ * providers are handed), and a root that cannot be resolved withholds the
530
+ * advisory rather than guessing — but only after the stamp has been seen, so
531
+ * an ordinary successful call costs no note. Once the root is known the
532
+ * classifier decides; a value that is a denial but names no in-workspace path
533
+ * is the designed escalation path and is left silent on purpose, which is why
534
+ * the `undefined` from the classifier is *not* routed through `withhold`.
535
+ * @param agent - the agent whose call was denied.
536
+ * @param previous - the agent's state before this call, if any.
537
+ * @param value - the settled call's canonical value.
538
+ * @param args - the settled call's parsed arguments.
539
+ * @param key - the identity of the denied call.
540
+ * @param tool - the denied tool's name, for the advisory context.
541
+ * @returns the notice to attach, or undefined.
542
+ */
543
+ function adviseWorkspaceDenial(agent, previous, value, args, key, tool) {
544
+ const resolution = resolveWorkspaceRoot(ctx, agent);
545
+ if (!resolution.ok)
546
+ return withhold(agent, previous, resolution.withheld, 'workspace-denial');
547
+ const failure = classifyWorkspaceDenial(value, args, {
548
+ platform: process.platform,
549
+ workspaceRoot: resolution.workspaceRoot,
550
+ });
551
+ if (failure === undefined)
552
+ return undefined;
553
+ if (!claimAdvice(agent, previous, failure, key))
554
+ return undefined;
555
+ ctx.logger.warn(workspaceDenialHostLine(failure));
556
+ return notice(advisoryText(failure, advisoryContext(tool)), summaryOf(failure));
557
+ }
558
+ /**
559
+ * The one-line host-side account of a recognized workspace-internal denial.
560
+ *
561
+ * It carries the path the plugin keyed on, because this family's whole claim
562
+ * is that one named path lies inside one named root — a maintainer reading the
563
+ * log is entitled to see both halves of the string comparison rather than a
564
+ * verdict about it.
565
+ * @param failure - the recognized failure.
566
+ * @returns a single log line.
567
+ */
568
+ function workspaceDenialHostLine(failure) {
569
+ const subject = failure.paths[0] ?? '<no path recovered>';
570
+ const more = failure.paths.length > 1 ? ` (+${String(failure.paths.length - 1)} more inside the same root)` : '';
571
+ return `sandbox-grant-advisor: confined command denied ${subject}${more}, which is INSIDE the workspace `
572
+ + `${failure.workspaceRoot}, under sandbox mode "${failure.mode}" — the root-only grant skipped this object and `
573
+ + `is never revisited, so retrying cannot help; advisory delivered to the model (discussion ${WORKSPACE_DENIAL_DISCUSSIONS})`;
574
+ }
471
575
  /**
472
576
  * The advisory context for one failing call.
473
577
  *
package/lib/mode.js CHANGED
@@ -19,6 +19,13 @@
19
19
  * session — so what is quoted in the advisory is the policy that actually
20
20
  * governed the failing call, not a guess reconstructed from configuration.
21
21
  *
22
+ * The same lookup answers the **workspace root** for the fourth family
23
+ * (`workspace-denial`, #423), whose claim is that the path a denied command
24
+ * named lies inside the session's own workspace. Same request, same fail-closed
25
+ * posture, separate function ({@link resolveWorkspaceRoot}), so that neither
26
+ * family can fail on a field it never reads and each can name the failure in its
27
+ * own words.
28
+ *
22
29
  * ## Why this is a guarded lookup instead of an import
23
30
  *
24
31
  * `@deepseek-ai/dsh-sandbox-policy` is **optional** in this plugin's world: a
@@ -106,10 +113,60 @@ function recognizedMode(resolved) {
106
113
  * @returns the mode, or the reason it could not be resolved.
107
114
  */
108
115
  export function resolveSandboxMode(ctx, agent) {
116
+ const lookup = policyOf(ctx, agent);
117
+ if (!lookup.ok)
118
+ return { ok: false, withheld: lookup.withheld };
119
+ const mode = recognizedMode(lookup.resolved);
120
+ if (mode === undefined) {
121
+ return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a recognizable mode' };
122
+ }
123
+ return { ok: true, mode };
124
+ }
125
+ /**
126
+ * The workspace root the policy resolver reports for one agent's call.
127
+ *
128
+ * The same lookup, the same request and the same fail-closed posture as
129
+ * {@link resolveSandboxMode}, and for the same reason: the root is what the
130
+ * enforcing providers were handed, so it is the value the workspace-denial
131
+ * family must test containment against rather than one reconstructed from
132
+ * configuration. The two are separate functions rather than one that returns
133
+ * both because each family needs one of them, and a family that needs only the
134
+ * mode must not be able to fail on a missing root (nor the other way round).
135
+ * @param ctx - the plugin's context.
136
+ * @param agent - the agent whose call failed.
137
+ * @returns the root, or the reason it could not be resolved.
138
+ */
139
+ export function resolveWorkspaceRoot(ctx, agent) {
140
+ const lookup = policyOf(ctx, agent);
141
+ if (!lookup.ok) {
142
+ return {
143
+ ok: false,
144
+ withheld: lookup.why === 'unmounted'
145
+ ? 'no `sandboxPolicy` service is mounted in this composition, so the workspace root is unknown'
146
+ : lookup.withheld,
147
+ };
148
+ }
149
+ const resolved = lookup.resolved;
150
+ const root = resolved === null || typeof resolved !== 'object'
151
+ ? undefined
152
+ : resolved.workspaceRoot;
153
+ if (typeof root !== 'string' || root.length === 0) {
154
+ return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a usable workspace root' };
155
+ }
156
+ return { ok: true, workspaceRoot: root };
157
+ }
158
+ /**
159
+ * Ask the mounted policy service for one agent's resolved policy.
160
+ * @param ctx - the plugin's context.
161
+ * @param agent - the agent whose call is being placed.
162
+ * @returns the resolver's answer, or the reason there is none.
163
+ */
164
+ function policyOf(ctx, agent) {
109
165
  const policy = asPolicy(ctx.get('sandboxPolicy'));
110
166
  if (policy === undefined) {
111
167
  return {
112
168
  ok: false,
169
+ why: 'unmounted',
113
170
  withheld: 'no `sandboxPolicy` service is mounted in this composition, so the effective mode is unknown',
114
171
  };
115
172
  }
@@ -117,19 +174,14 @@ export function resolveSandboxMode(ctx, agent) {
117
174
  if (session === undefined) {
118
175
  return {
119
176
  ok: false,
177
+ why: 'no-session',
120
178
  withheld: 'the agent exposes no session, and the policy must be resolved from it rather than from the deployment default',
121
179
  };
122
180
  }
123
- let resolved;
124
181
  try {
125
- resolved = policy.resolve({ session });
182
+ return { ok: true, resolved: policy.resolve({ session }) };
126
183
  }
127
184
  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' };
185
+ return { ok: false, why: 'threw', withheld: `\`sandboxPolicy.resolve\` threw (${String(error)})` };
133
186
  }
134
- return { ok: true, mode };
135
187
  }