@argszero/cordis-plugin-sandbox-grant-advisor 0.10.0 → 0.11.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/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # @argszero/cordis-plugin-sandbox-grant-advisor
2
2
 
3
3
  Turns a sandbox environment failure that has **no path forward** into a
4
- diagnosis the model — and the user reading the transcript — can act on. Three
4
+ diagnosis the model — and the user reading the transcript — can act on. Four
5
5
  signatures, one mechanism:
6
6
 
7
7
  ```
8
8
  SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws) # Windows workspace ACL
9
9
  PTY shell exited during startup # persistent shell × confining mode
10
10
  [exit code: -1073741502] (0xC0000142) # a confined child that never started
11
+ [exit code: 1] sandbox: { mode: "workspace-write", denied: true }
12
+ # denied INSIDE the workspace
11
13
  ```
12
14
 
13
15
  **This plugin is the stopgap for "the error does not name the outstanding
@@ -15,7 +17,7 @@ condition".** It repairs nothing: no ACL is written, no privilege is requested,
15
17
  nothing is elevated, no environment variable is set for another process, no
16
18
  preset is installed and no mode is changed.
17
19
 
18
- ## The three failures it recognizes
20
+ ## The four failures it recognizes
19
21
 
20
22
  The first two are recognized on the public **`tools/post-execute`** waterfall
21
23
  (`@deepseek-ai/dsh-tools`) from the failure text. That seam is the one that has
@@ -24,9 +26,9 @@ all three of what a diagnosis needs: the failure
24
26
  `isError` result), an agent identity to attribute it to (`exec.agent`), and a
25
27
  channel that speaks to the model in the same step (`PostToolDecision`'s
26
28
  `additionalContexts`, which the agent loop turns into a durable user-role message
27
- — `packages/core/agent-loop/src/tool-calls.ts`). The third is recognized at the
28
- **same seam** from the canonical value of a result the pipeline calls a
29
- *success*, for a reason §3 gives in full.
29
+ — `packages/core/agent-loop/src/tool-calls.ts`). The third and fourth are
30
+ recognized at the **same seam** from the canonical value of a result the pipeline
31
+ calls a *success*, for reasons §3 and §4 give in full.
30
32
 
31
33
  That seam, not `ctx.sandbox.confine`: `confine(argv, policy, signal)` sees the
32
34
  confinement failure too, but its signature carries no agent, so a wrapper could
@@ -621,6 +623,110 @@ the single shape that happened to be measured first. It never
621
623
  offers `danger-full-access` as a fix and never suggests a sandbox setting be
622
624
  relaxed.
623
625
 
626
+ ### 4. Denied inside the workspace (`workspace-denial`, added in 0.11.0)
627
+
628
+ [#423] is one report and its own follow-up, and it is the **other end of the
629
+ backend §1 is about**. There the workspace grant could not be applied at all and
630
+ every command died before it ran; here the grant **was** applied — on the
631
+ workspace root, once — and part of the tree still refuses writes, forever. The
632
+ report's shape: under `workspace-write` on Windows, a command writing into a
633
+ subdirectory that was created or **moved in from outside** the session (an
634
+ installer, an editor, another harness running under its own account) is denied,
635
+ while the same command against a directory the harness itself created succeeds.
636
+
637
+ ```
638
+ [exit code: 1] sandbox: { mode: "workspace-write", denied: true }
639
+ ```
640
+
641
+ **The signature is a value, not a message**, and this family is invisible from
642
+ the error path for the same structural reason §3 is: a denied command exits
643
+ nonzero, and the shipped shell tools report a nonzero exit as a finished run
644
+ rather than as `isError`
645
+ (`packages/shell/tool-pwsh/src/render.ts` reports `[exit code: N]` and drops a
646
+ denial marker). The fact therefore arrives in `ToolExecutionSuccess.value`,
647
+ where `tool-bash` / `tool-pwsh` project what the sandbox executor stamped
648
+ (`packages/shell/bash-sandbox/src/index.ts`, `pwsh-sandbox`): the mode the call
649
+ actually ran under, whether the backend's own refusal dialect appears in the
650
+ **captured stderr**, and the enforcement that applied. Reading the executors'
651
+ own stamp rather than a sentence means this plugin is reporting the harness's
652
+ reading of its own sandbox, not a guess about a line of output.
653
+
654
+ **What the advisory says.** Four things, and one it refuses:
655
+
656
+ - **That retrying is provably useless.** The host-side grant is written once, on
657
+ the workspace **root**, and relies on Windows ACE inheritance to reach the
658
+ tree beneath it. Writing an inherited ACE into an *already-existing* child
659
+ needs `WRITE_DAC` on that child; where the caller does not hold it, Windows
660
+ skips the child silently — no error, no return value, no log line. The backend
661
+ then checks only the root (`hasExactGrant(workspaceRoot)` in
662
+ `packages/sandbox/sandbox-windows-acl/src/acl.ts`) and returns early once the
663
+ grant is there, which it is from the first call onwards. The descendants that
664
+ missed the propagation are never revisited — not later in this session, not in
665
+ any later one.
666
+ - **Which objects miss it, and how many.** It is a fact about *who created
667
+ them*: objects the harness creates inherit the ACE, and objects that already
668
+ existed do not. [#423] measured 170 of 729 objects missing it, **including
669
+ root-level files** — so "write at the workspace root instead" is not a safe
670
+ move either.
671
+ - **The discriminator, and its second half.** The advisory prints the path it
672
+ keyed on beside the root it tested it against, so the reader can audit the
673
+ claim instead of taking a statement about two strings on faith. Then the
674
+ measurement a single check gets wrong: reading and listing use the **normal**
675
+ token while writing and deleting use the **restricted** (low-integrity) one,
676
+ and both sides must pass — so an object whose DACL names only
677
+ `Administrators`/`SYSTEM` plus the capability SID is refused on the read side
678
+ too, and looks fine to a check that merely greps for the capability SID.
679
+ [#423] measured exactly that on a `.cache` directory.
680
+ - **The second measured variant of the same shape.** The coverage that is
681
+ missing can be the mandatory-integrity **label** rather than a DACL entry —
682
+ reported in the same thread on 2026-09-29, against a `0.2.0-rc.1` install,
683
+ under the same root-only short-circuit. From inside a session the two are
684
+ indistinguishable; from outside, the repository's own diagnosis skill
685
+ separates them (`diagnose-windows-sandbox-acl`, 0.2.0 and later, reports
686
+ `hasExactDeny()` for the DACL half and `LOW_LABEL` — `S-1-16-4096` — for the
687
+ label half).
688
+ - **No repair command.** The obvious one, a recursive `icacls /grant` for the
689
+ capability SID, is refused by Windows itself with `ERROR_NONE_MAPPED` (1332) —
690
+ the tool cannot map that SID to a name, so a grant that must name it never
691
+ reaches the child. The line that *would* work needs `WRITE_DAC` on the object,
692
+ which is the right in question. The advisory states the mechanism, names the
693
+ ceiling, and says out loud that it prints no command because this project has
694
+ no Windows host to verify one on — the same standard §1's standing-grant
695
+ section is held to, and for the same reason.
696
+
697
+ **What it refuses to explain, and why that is the design.** A denial is the
698
+ *sanctioned* outcome in three other situations, and advising about a missing
699
+ inherited grant in any of them would be a confidently wrong cause:
700
+
701
+ - **Outside the workspace** — a confining sandbox denying a path outside its
702
+ writable roots is the whole point, and the denial surface's one-shot escalation
703
+ offer is correct there.
704
+ - **Under `read-only`** — that mode denies every write by construction, so an
705
+ in-workspace denial under it is the mode working.
706
+ - **A runner failure** — the executor refuses to call a run denied when the
707
+ runner itself failed, and such a value is left alone for the same reason.
708
+
709
+ What is left is exactly the anomaly: a denial under `workspace-write` of a path
710
+ inside the session's own workspace. That is why the family reads the mode off
711
+ the **value** (the executor stamped the mode it actually ran under, so no policy
712
+ lookup can disagree with it), tests containment against the root the policy
713
+ resolver reports, and speaks only when **every** absolute path the command's own
714
+ arguments name lies under that root. A command that names an outside path as
715
+ well — an interpreter under `C:\Program Files`, an output directory on another
716
+ volume — is refused rather than guessed at, and so is a command naming only
717
+ relative paths: in both cases the plugin cannot say *which* path was denied, and
718
+ silence is the fail-closed direction. The platform gate is real here and cannot
719
+ be dropped: the mechanism is ACE inheritance, which no other backend has.
720
+
721
+ **On a denial it cannot finish placing** — a root the policy resolver does not
722
+ report, or no mounted policy service at all — the plugin withholds the advisory
723
+ and says so once on the host log. That is the disclosure rule §1's mode gate
724
+ follows as well: silence alone would make "the sandbox is not the cause"
725
+ indistinguishable from "this plugin could not tell".
726
+
727
+ [#423] is a Discussion (the repository has issues disabled), and the reply
728
+ covering this family is posted there.
729
+
624
730
  ## What it does with a recognized failure
625
731
 
626
732
  1. **One durable advisory per agent, per family.** An agent that hits two
@@ -631,10 +737,12 @@ relaxed.
631
737
  `warn` line, so the fact survives outside the transcript too.
632
738
  2. **A disclosure when it withholds.** The PTY and native-init advisories are
633
739
  only sent when the
634
- resolved mode actually confines. If the mode is `danger-full-access`, or
635
- cannot be resolved at all (no `sandboxPolicy` service mounted, no agent
636
- session, a resolver that throws), the failure is left exactly as it was
637
- **and the host log says so once**. Silence alone would make "the sandbox is
740
+ resolved mode actually confines, and the workspace-denial advisory only when
741
+ the workspace root is resolvable — the fact its containment claim is tested
742
+ against. If the mode is `danger-full-access`, or either fact cannot be
743
+ resolved at all (no `sandboxPolicy` service mounted, no agent session, a
744
+ resolver that throws, a policy without a root), the failure is left exactly as
745
+ it was **and the host log says so once**. Silence alone would make "the sandbox is
638
746
  not the cause" and "this plugin could not tell" indistinguishable from the
639
747
  outside. Withholding is never a guess: an unresolvable mode is *not* an
640
748
  invitation to fall back to the deployment default.
@@ -649,8 +757,8 @@ relaxed.
649
757
  refused twice — while a session can always make progress by spending the
650
758
  budget it has.
651
759
 
652
- **Why the blocking half does not extend to the two mode-gated families** (it
653
- is ACL-only by construction, in the parameter type): the ACL remedy is a
760
+ **Why the blocking half covers one family only** (it is ACL-only by
761
+ construction, in the parameter type): the ACL remedy is a
654
762
  command
655
763
  the user can run *while the session continues*, so refusing further identical
656
764
  calls cannot make the session unfinishable — spending the budget always lets
@@ -659,8 +767,11 @@ relaxed.
659
767
  native-init remedy is a launch fix on the user's side — whose one in-session
660
768
  part, rewriting an MSYS2 command, the model does by calling a *different*
661
769
  command, which has a different call key and is therefore never the call being
662
- refused. Refusing calls in those families could only pad a session that is
663
- already unable to do the thing being refused.
770
+ refused. The workspace-internal denial is not covered either, and for a
771
+ stronger version of the same reason: its remedy is not a command at all — the
772
+ object was skipped when the grant was written and nothing revisited it — so
773
+ refusing calls could only pad a session that is already unable to do the thing
774
+ being refused.
664
775
 
665
776
  ## Install
666
777
 
@@ -734,6 +845,15 @@ than one that stays silent.
734
845
  `[exit code: -1073741502]`, or any value that is not the shipped shell
735
846
  projection (`kind: 'foreground'`), is not this family — a line of text can
736
847
  never be mistaken for a loader status.
848
+ - **A denial is not this family unless the plugin can place it.** A denial under
849
+ `read-only`, under `danger-full-access`, or on a host that is not Windows; a
850
+ value the executor marked as a runner failure (`denied` is not set when the
851
+ runner itself failed); a command naming a path outside the workspace; a command
852
+ naming an outside path as well as an inside one; and a command naming only
853
+ relative paths — all six are refused, and the first three are the *sanctioned*
854
+ outcomes rather than puzzles. The plugin reads the executors' own stamp rather
855
+ than a sentence, so a command whose output contains `denied: true` is not this
856
+ family either.
737
857
  - **Only one advisory per agent, per family.** The environment is explained
738
858
  once; repeating it per failed command would be noise competing with the
739
859
  failure itself.
@@ -742,9 +862,11 @@ than one that stays silent.
742
862
 
743
863
  - **The Windows path itself cannot be witnessed on macOS**, where this plugin
744
864
  was built. What the test suite proves is the decision layer — classification
745
- of all three families (the third from the producer's own canonical value,
865
+ of all four families (the third from the producer's own canonical value,
746
866
  built by the suite with the reported `-1073741502` and the report's stderr
747
- line), the once-per-agent-per-family rule, the sandbox-mode gate
867
+ line; the fourth from the executors' own stamp, built by the suite with the
868
+ mode, the `denied` flag and the refusal dialect the executor matches), the
869
+ once-per-agent-per-family rule, the sandbox-mode gate
748
870
  and its fail-closed behaviour, the fail-fast budget and its self-feeding
749
871
  guard, and the wiring to a real cordis `Context` and the real `ToolRuntime` —
750
872
  driven by fixtures that throw the producers' exact error shapes
@@ -754,7 +876,14 @@ than one that stays silent.
754
876
  a given Windows host reproduces the PTY startup failure; those are the user's
755
877
  one-line experiment and the reporter's own control, and both advisories say
756
878
  where they stop. The native-init family is the one that needs no Windows to be
757
- faithful, because what it reads is a number inside a JSON value.
879
+ faithful, because what it reads is a number inside a JSON value. The
880
+ workspace-denial family is exercised the same way and **does** need the
881
+ platform fact, which the suite supplies by stubbing `process.platform` for the
882
+ arms that need `win32` and restoring it — the plugin reads the real fact rather
883
+ than a config knob, because a knob would be a backdoor into a shipped decision.
884
+ What that proves is the decision layer against the producers' stamped values;
885
+ the ACE-inheritance path itself is still unwitnessed here, and the advisory
886
+ says as much by shipping no repair command.
758
887
  - **The standing-grant section is source-level, and the out-of-tree reach is the
759
888
  reporter's measurement.** What this section states about the backend's own
760
889
  behaviour — that the workspace grant is standing, that nothing in the dispose or
@@ -785,7 +914,7 @@ than one that stays silent.
785
914
  outside the seam this plugin subscribes to. The report and its proposed fix
786
915
  stay with the maintainers; all this plugin can do is explain the provisioning
787
916
  failure that shares its root.
788
- - **The real fix is upstream, in all three families.** For the ACL failure,
917
+ - **The real fix is upstream, in all four families.** For the ACL failure,
789
918
  `grantWrite` already computes `hasExactGrant` / `hasExactDeny` /
790
919
  `hasExactLabel` and discards which one was false, so the diagnostic that turns
791
920
  a 52-minute detour into one line belongs at that site. For the PTY failure,
@@ -794,7 +923,12 @@ than one that stays silent.
794
923
  the runner should be launched with the environment its own execution needs
795
924
  (`ELECTRON_RUN_AS_NODE=1` when `argv[0]` is an Electron binary — [`#7876`]'s
796
925
  three candidate fixes) or refuse, in a checkable way, an MSYS2 program under a
797
- restricted token. This plugin is the stopgap for all three.
926
+ restricted token. For the workspace-internal denial the site is the same
927
+ `grantWrite`: its early return asks only whether the **root** already carries
928
+ the ACE, so the descendants that missed the propagation are never repaired —
929
+ the check would have to look past the root, or the denial surface would have to
930
+ say *which* path was refused instead of only that one was. This plugin is the
931
+ stopgap for all four.
798
932
 
799
933
  ## Compatibility
800
934
 
@@ -885,6 +1019,7 @@ the current runtime cannot distinguish rather than counting it as a pass.
885
1019
  [discussion #7876]: https://github.com/deepseek-ai/deepseek-harness/discussions/7876
886
1020
  [discussion #7877]: https://github.com/deepseek-ai/deepseek-harness/discussions/7877
887
1021
  [discussion #8208]: https://github.com/deepseek-ai/deepseek-harness/discussions/8208
1022
+ [#423]: https://github.com/deepseek-ai/deepseek-harness/discussions/423
888
1023
  [#7750]: https://github.com/deepseek-ai/deepseek-harness/discussions/7750
889
1024
  [#7771]: https://github.com/deepseek-ai/deepseek-harness/discussions/7771
890
1025
  [#7804]: https://github.com/deepseek-ai/deepseek-harness/discussions/7804
package/cordis.patch.yml CHANGED
@@ -125,6 +125,40 @@
125
125
  # itself (rewrite the work as PowerShell or `cmd`), names the remedy #8193
126
126
  # measured — a real node.exe host, which the desktop ships — and says plainly
127
127
  # which producers it does not know. Like the second family, it is mode-gated.
128
+ #
129
+ # A FOURTH family is the other half of the FIRST one's backend (#423). There the
130
+ # grant could not be applied at all; here it WAS applied — on the workspace root,
131
+ # once — and Windows ACE inheritance silently skipped the objects whose DACL the
132
+ # caller could not write, so commands writing into a subdirectory created or moved
133
+ # in from outside (an installer, an editor, another harness under its own account)
134
+ # are denied forever while the same command against a directory the harness itself
135
+ # created succeeds. The backend's provisioning check short-circuits on the root
136
+ # (`hasExactGrant` returns early once the root carries the ACE), so those
137
+ # descendants are never revisited. It is read from a value rather than from text,
138
+ # like the third family and for the same reason (a denial exits nonzero and the
139
+ # shipped shell tools report that as a finished run), but it needs no bespoke code:
140
+ # the executors stamp the denial onto the value as
141
+ #
142
+ # sandbox: { mode: "workspace-write", denied: true }
143
+ #
144
+ # produced by matching the backend's own refusal dialect in the captured stderr —
145
+ # so this plugin reads the harness's own reading of its own sandbox. It speaks
146
+ # only when the host is Windows, the stamped mode is `workspace-write`, and EVERY
147
+ # absolute path the command names lies inside the session's workspace: a denial
148
+ # anywhere else (outside the workspace, under `read-only`, a runner failure) is
149
+ # the sandbox working as designed and is left alone. The advisory prints the path
150
+ # it keyed on beside the root it tested it against, says that retrying is provably
151
+ # useless, gives the DISCRIMINATOR — reading/listing use the normal token while
152
+ # writing/deleting use the restricted one, and both sides must pass, so an object
153
+ # whose DACL names only Administrators/SYSTEM plus the capability SID looks granted
154
+ # to a check that just greps for the SID — names the second measured variant of
155
+ # the same shape (the coverage missing on the mandatory-integrity LABEL rather
156
+ # than on the DACL, separated from outside a session by the repository's own
157
+ # `diagnose-windows-sandbox-acl`), and states the Windows ceiling that closes the
158
+ # obvious repair (a recursive `icacls /grant` for a capability SID is refused with
159
+ # ERROR_NONE_MAPPED (1332), because that SID has no name to map). It ships NO
160
+ # repair command, for the reason the standing-grant section ships none: this
161
+ # project has no Windows host to verify a line on.
128
162
 
129
163
  - insert:
130
164
  - id: sandbox-grant-advisor
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
  */
@@ -159,6 +190,33 @@ export const PTY_DISCUSSIONS = '#7638 / #8322';
159
190
  * now names.
160
191
  */
161
192
  export const NATIVE_INIT_DISCUSSIONS = '#7876 / #7877 / #8193 / #8208 / #8313';
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
  /**
@@ -713,6 +773,117 @@ function ptyAdvisory(failure, mode, tool, href) {
713
773
  'shell can fail to start for other reasons and a confident wrong cause is worse than no answer.',
714
774
  ].join('\n');
715
775
  }
776
+ /**
777
+ * Build the advisory for a confined command that was denied a path inside its
778
+ * own workspace.
779
+ *
780
+ * Four things this text must do, and one it must not. It must lead with the
781
+ * fact that **retrying is provably useless** (the backend's provisioning check
782
+ * short-circuits on the root, so the object that missed the propagation is never
783
+ * revisited) — that is the whole reason the model needs telling rather than
784
+ * discovering. It must show the **path it keyed on and the root it tested it
785
+ * against**, because the family's claim is a statement about two strings and the
786
+ * reader is entitled to audit it. It must give the **two-sided** reachability
787
+ * rule, since a check that only looks for the capability ACE reports an object
788
+ * as granted when the read side is refused as well. And it must name the
789
+ * **label variant** of the same shape, because from inside a session the two are
790
+ * indistinguishable and a reader who repairs the wrong half has learned
791
+ * nothing.
792
+ *
793
+ * What it must not do is print a repair command. The obvious one is refused by
794
+ * Windows with `ERROR_NONE_MAPPED` (1332) — a capability SID has no name for
795
+ * `icacls` to map — and the one that would work needs `WRITE_DAC` on the object,
796
+ * which is the right in question. This project has no Windows host to verify a
797
+ * line on, and the standing-grant section of the ACL advisory is held to the
798
+ * same standard for the same reason: an unverified remedy delivered confidently
799
+ * is the defect this plugin exists to answer. The mechanism is stated instead,
800
+ * and the reader is told why the command is absent.
801
+ * @param failure - the recognized failure.
802
+ * @param tool - the tool whose call was denied, when the caller knows it.
803
+ * @param href - optional URL shown for the upstream thread.
804
+ * @returns the user-role notice text.
805
+ */
806
+ function workspaceDenialAdvisory(failure, tool, href) {
807
+ const where = href === undefined
808
+ ? `tracked upstream (discussion ${WORKSPACE_DENIAL_DISCUSSIONS})`
809
+ : `tracked upstream: ${href}`;
810
+ const call = tool === undefined ? 'This command' : `The \`${tool}\` command`;
811
+ const subject = failure.paths[0] ?? '<the path from the error line above>';
812
+ return [
813
+ 'Denied inside your own workspace — the workspace grant does not reach that part of the tree, and retrying cannot repair it.',
814
+ '',
815
+ 'What was reported:',
816
+ ` ${failureLine(failure)}`,
817
+ `${call} ran under sandbox mode \`${failure.mode}\`, where a write INSIDE the workspace is supposed to succeed, and every`,
818
+ 'path it named is inside this session\'s workspace:',
819
+ ...failure.paths.map(path => ` ${path}`),
820
+ ` (workspace root: ${failure.workspaceRoot})`,
821
+ 'So this is not the sandbox declining work that belongs outside the workspace. It is the workspace\'s own grant',
822
+ 'failing to cover an object inside it, which is why every command touching that object fails the same way.',
823
+ '',
824
+ 'Why — and why retrying cannot fix it:',
825
+ ' The host-side grant is written ONCE, on the workspace ROOT, and relies on Windows ACE inheritance to reach',
826
+ ' everything beneath it. Writing an inherited ACE into an ALREADY-EXISTING child needs WRITE_DAC on that child;',
827
+ ' where the caller does not hold it, Windows skips the child silently — no error, no return value, no log line.',
828
+ ' The backend then checks only the root (`hasExactGrant(workspaceRoot)`) and returns early when the grant is',
829
+ ' already there, which it is from the first call onwards. The descendants that missed the propagation are',
830
+ ' therefore never revisited — not later in this session, not in any later one — so the identical command keeps',
831
+ ' failing and there is no number of attempts that changes that',
832
+ ' (`packages/sandbox/sandbox-windows-acl/src/acl.ts`).',
833
+ ' WHICH objects miss it is a fact about who created them: objects the harness itself creates inherit the ACE,',
834
+ ' while objects that already existed or that another account or tool created (an installer, an editor, another',
835
+ ' agent harness running under its own account) are the ones the propagation skipped. #423 measured 170 of 729',
836
+ ' objects missing it, INCLUDING root-level files — so it is not only subdirectories, and "write it at the',
837
+ ' workspace root instead" is not a safe move either.',
838
+ '',
839
+ 'Confirm it — this is the discriminator, and the second half is the part a single check gets wrong:',
840
+ ` icacls "${subject}"`,
841
+ 'compared with the same command against the workspace root. The root carries an inheritable ACE for the workspace',
842
+ 'capability SID — a `S-1-4-…` that `icacls` prints as an unresolved SID rather than a name — with Modify or Full',
843
+ 'control; the failing object does not have it. Check each path listed above the same way; the first is shown here.',
844
+ ' THE TRAP: reading and listing go through the NORMAL token, writing and deleting through the RESTRICTED',
845
+ ' (low-integrity) one, and both sides must pass. An object whose DACL names only Administrators/SYSTEM plus the',
846
+ ' capability SID is refused on the read side as well, so it looks granted to a check that only searches for the',
847
+ ' capability SID — #423 measured exactly that on a `.cache` directory. A check that asks only "is the SID there?"',
848
+ ' reports those objects as fine while LIST and WRITE are both denied.',
849
+ ' A SECOND measured variant has the same shape and a different object: the coverage that is missing can be the',
850
+ ' mandatory-integrity LABEL rather than a DACL entry. It was reported in this same thread on 2026-09-29 against a',
851
+ ' `0.2.0-rc.1` install, under the same root-only short-circuit. Inside a session the two are indistinguishable;',
852
+ ' from outside, the repository\'s own diagnosis skill separates them — `diagnose-windows-sandbox-acl` (0.2.0 and',
853
+ ' later) reports `hasExactDeny()` for the DACL half and `LOW_LABEL` (`S-1-16-4096`) for the label half.',
854
+ '',
855
+ 'Why the natural repair is closed — this is a Windows ceiling, not a mistake in the command:',
856
+ ' The grant the root carries is written for a capability SID, and a recursive `icacls /grant "*S-1-4-…:…" /T /C`',
857
+ ' is refused with ERROR_NONE_MAPPED (1332): the tool cannot map that SID to a name, so a grant that needs to name',
858
+ ' it never reaches the child. Widening the grant to a nameable account is a different grant with different',
859
+ ' consequences, not this one restored.',
860
+ '',
861
+ 'What helps:',
862
+ ' - The move that is yours, and the only one available inside the session: write new files under a directory the',
863
+ ' harness itself created in this workspace. Those carry the grant, because their inheritance did apply — the',
864
+ ' reporter\'s own measurement, that objects DSH created are complete here and externally created ones are not.',
865
+ ' - The user\'s move: repair the specific object from an account that already holds WRITE_DAC on it, by writing',
866
+ ' the ACE the root carries into the child\'s own DACL. That needs the very right that is missing, so it is not',
867
+ ' something an unelevated prompt can do.',
868
+ ' No repair command is printed here on purpose. This project has no Windows host to verify one on, and shipping',
869
+ ' an unverified line would be the same defect this plugin exists to answer. The mechanism above is what is known;',
870
+ ' the line is yours to choose.',
871
+ '',
872
+ 'The retry you are about to be offered: the denial surface offers one retry of this exact command under a wider',
873
+ 'mode. That retry can only succeed by removing the confinement itself — it repairs nothing in the workspace, and',
874
+ 'the next session meets the same gap. Take it only if that is what you mean to buy.',
875
+ '',
876
+ 'Do NOT retry this call unchanged: the provisioning path short-circuits on the root grant, so the object that was',
877
+ 'skipped is never revisited.',
878
+ '',
879
+ 'How to read this: the denial names no cause and points at no repair, so the diagnosis is delivered here instead.',
880
+ 'This is a stopgap, ' + where + '. This plugin speaks only when the mode is `workspace-write`, the host is',
881
+ 'Windows, and every path the command names lies inside the workspace — a denial anywhere else is the sandbox',
882
+ 'working as designed, and a confident wrong cause is worse than no answer.',
883
+ 'What it is NOT: this plugin does not edit an ACL, does not elevate, and does not offer `danger-full-access` as a',
884
+ 'fix.',
885
+ ].join('\n');
886
+ }
716
887
  /**
717
888
  * Build the pre-dispatch denial for the optional fail-fast half.
718
889
  *