@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 +153 -18
- package/cordis.patch.yml +34 -0
- package/lib/advice.js +172 -1
- package/lib/index.js +128 -24
- package/lib/mode.js +60 -8
- package/lib/signature.js +202 -1
- package/lib/types/advice.d.ts +55 -2
- package/lib/types/index.d.ts +48 -9
- package/lib/types/mode.d.ts +34 -0
- package/lib/types/signature.d.ts +189 -3
- package/package.json +2 -2
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.
|
|
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
|
|
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
|
|
28
|
-
**same seam** from the canonical value of a result the pipeline
|
|
29
|
-
*success*, for
|
|
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
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
|
653
|
-
|
|
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.
|
|
663
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
*
|