@argszero/cordis-plugin-sandbox-grant-advisor 0.1.0 → 0.2.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 +205 -76
- package/cordis.patch.yml +18 -0
- package/lib/advice.js +130 -18
- package/lib/index.js +208 -51
- package/lib/mode.js +135 -0
- package/lib/signature.js +56 -5
- package/lib/state.js +121 -44
- package/lib/types/advice.d.ts +82 -20
- package/lib/types/index.d.ts +81 -36
- package/lib/types/mode.d.ts +87 -0
- package/lib/types/signature.d.ts +68 -5
- package/lib/types/state.d.ts +84 -34
- package/package.json +4 -3
package/lib/types/signature.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Recognize the
|
|
2
|
+
* Recognize the two environment failures this plugin explains, and refuse
|
|
3
|
+
* everything else.
|
|
4
|
+
*
|
|
5
|
+
* ## The ACL provisioning failure (`acl-provisioning`)
|
|
3
6
|
*
|
|
4
7
|
* The harness's Windows sandbox provisions a workspace by writing the
|
|
5
8
|
* directory's DACL and its mandatory-integrity label in **one**
|
|
@@ -10,7 +13,7 @@
|
|
|
10
13
|
* it. Every sandboxed command then fails the same way, forever, because the
|
|
11
14
|
* grant is materialized lazily and nothing is cached on the failure path.
|
|
12
15
|
*
|
|
13
|
-
* Recognizing the string is therefore the whole job of this
|
|
16
|
+
* Recognizing the string is therefore the whole job of this half, and the
|
|
14
17
|
* recognition is deliberately narrow:
|
|
15
18
|
*
|
|
16
19
|
* - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
|
|
@@ -27,6 +30,28 @@
|
|
|
27
30
|
* the advisory can quote the exact line the model and the user are looking
|
|
28
31
|
* at, and the path can be re-used in the fix command.
|
|
29
32
|
*
|
|
33
|
+
* ## The persistent-shell startup failure (`pty-startup`)
|
|
34
|
+
*
|
|
35
|
+
* `dsh-terminal-bash` throws `PTY shell exited during startup` when the shell
|
|
36
|
+
* it spawned through the sandbox exits before reaching its first prompt
|
|
37
|
+
* (`src/session.ts` and `src/index.ts`, both on the same `waitReason ===
|
|
38
|
+
* 'session_exit'` branch). The text names no cause and, under the `minimal`
|
|
39
|
+
* preset — whose only shell tool is a persistent PTY — there is no other shell
|
|
40
|
+
* tool left to fall back on, so the model reads it as "the command failed" and
|
|
41
|
+
* retries forever. #7638 is the report: 33 consecutive failures under
|
|
42
|
+
* `workspace-write`, none under `danger-full-access`, with the reporter's own
|
|
43
|
+
* three-arm control showing the sandbox mode is the discriminator.
|
|
44
|
+
*
|
|
45
|
+
* This family is recognized on an **exact line**, not on a substring, and that
|
|
46
|
+
* is deliberate. The producer's message has no detail field at all — the whole
|
|
47
|
+
* message is the sentence — so anything that merely *contains* the phrase is
|
|
48
|
+
* quoting it (a transcript, a log a failing command printed, a pasted issue
|
|
49
|
+
* body) rather than producing it. The sibling throw on the same branch, `PTY
|
|
50
|
+
* shell did not reach readiness before startup timeout`, is **not** classified
|
|
51
|
+
* here: it means the shell started and then did not reach a prompt, which is a
|
|
52
|
+
* different cause space (a slow or blocked shell) with a different remedy, and
|
|
53
|
+
* a classifier that names a wrong cause is worse than one that stays silent.
|
|
54
|
+
*
|
|
30
55
|
* @module
|
|
31
56
|
*/
|
|
32
57
|
/** Which provisioning operation failed, and which diagnosis follows from it. */
|
|
@@ -37,8 +62,16 @@ export type FailureClass =
|
|
|
37
62
|
| 'apply-other'
|
|
38
63
|
/** `GetNamedSecurityInfoW` failed: the security descriptor could not even be read. */
|
|
39
64
|
| 'read-denied';
|
|
65
|
+
/** The environment failure family a recognized failure belongs to. */
|
|
66
|
+
export type FailureFamily =
|
|
67
|
+
/** The Windows sandbox could not provision its workspace (ACL / mandatory label). */
|
|
68
|
+
'acl-provisioning'
|
|
69
|
+
/** The persistent PTY shell could not start under a confining sandbox mode. */
|
|
70
|
+
| 'pty-startup';
|
|
40
71
|
/** One recognized provisioning failure, with the producer's own fields kept. */
|
|
41
72
|
export interface ProvisioningFailure {
|
|
73
|
+
/** Which family this failure belongs to. */
|
|
74
|
+
readonly family: 'acl-provisioning';
|
|
42
75
|
/** Which diagnosis follows from the api/code pair. */
|
|
43
76
|
readonly klass: FailureClass;
|
|
44
77
|
/** The API whose checked result failed, exactly as the producer names it. */
|
|
@@ -52,15 +85,45 @@ export interface ProvisioningFailure {
|
|
|
52
85
|
/** The directory the detail names, when it has that shape. */
|
|
53
86
|
readonly path?: string;
|
|
54
87
|
}
|
|
88
|
+
/** The exact text `dsh-terminal-bash` throws when the shell exits during startup. */
|
|
89
|
+
export declare const PTY_STARTUP_EXIT = "PTY shell exited during startup";
|
|
55
90
|
/**
|
|
56
|
-
*
|
|
91
|
+
* The persistent-shell startup failure. It carries no producer fields: the
|
|
92
|
+
* producer's entire message is {@link PTY_STARTUP_EXIT}, and what makes the
|
|
93
|
+
* diagnosis actionable (the effective sandbox mode) comes from the policy
|
|
94
|
+
* resolver at the call site rather than from the error text.
|
|
95
|
+
*/
|
|
96
|
+
export interface PtyStartupFailure {
|
|
97
|
+
/** Which family this failure belongs to. */
|
|
98
|
+
readonly family: 'pty-startup';
|
|
99
|
+
/** The producer's message, kept as the constant so nothing can drift. */
|
|
100
|
+
readonly line: string;
|
|
101
|
+
}
|
|
102
|
+
/** Any failure this plugin recognizes, tagged by family. */
|
|
103
|
+
export type RecognizedFailure = ProvisioningFailure | PtyStartupFailure;
|
|
104
|
+
/**
|
|
105
|
+
* Classify one failure message against the ACL family.
|
|
57
106
|
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
58
107
|
* @returns the recognized failure, or undefined when this is not a provisioning failure.
|
|
59
108
|
*/
|
|
60
109
|
export declare function classifyProvisioningFailure(message: string): ProvisioningFailure | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Classify one failure message as the persistent-shell startup failure.
|
|
112
|
+
*
|
|
113
|
+
* Recognition is by **whole line**, because the producer's message is a bare
|
|
114
|
+
* sentence with no fields of its own: a line equal to
|
|
115
|
+
* {@link PTY_STARTUP_EXIT} — with only the `Error: ` envelope a tool result adds
|
|
116
|
+
* in front of it — is the producer. A longer line that happens to contain the
|
|
117
|
+
* sentence is something quoting it (a transcript, a log the failing command
|
|
118
|
+
* printed, a pasted issue body), and advising about the sandbox there would be
|
|
119
|
+
* advice about the wrong thing.
|
|
120
|
+
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
121
|
+
* @returns the recognized failure, or undefined when this is not one.
|
|
122
|
+
*/
|
|
123
|
+
export declare function classifyPtyStartupFailure(message: string): PtyStartupFailure | undefined;
|
|
61
124
|
/**
|
|
62
125
|
* The one-line failure the producer wrote, for quoting back verbatim.
|
|
63
126
|
* @param failure - a recognized failure.
|
|
64
|
-
* @returns the message text
|
|
127
|
+
* @returns the message text the producing layer would have produced.
|
|
65
128
|
*/
|
|
66
|
-
export declare function failureLine(failure:
|
|
129
|
+
export declare function failureLine(failure: RecognizedFailure): string;
|
package/lib/types/state.d.ts
CHANGED
|
@@ -6,35 +6,60 @@
|
|
|
6
6
|
* failing call carries `exec.agent`, one agent owns one session, and a WeakMap
|
|
7
7
|
* keyed by the agent object lets a finished session's state be collected.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* feeding on itself:
|
|
9
|
+
* ## One record per family
|
|
11
10
|
*
|
|
12
|
-
*
|
|
11
|
+
* This plugin now recognizes two unrelated environment failures — a workspace
|
|
12
|
+
* that cannot be provisioned (`acl-provisioning`) and a persistent shell that
|
|
13
|
+
* cannot start (`pty-startup`). They are different diagnoses with different
|
|
14
|
+
* remedies, so their bookkeeping is kept apart under one agent
|
|
15
|
+
* ({@link AgentState.families}): an agent that hits both is told about both,
|
|
16
|
+
* and an agent that has already been told about one is still told about the
|
|
17
|
+
* other. Sharing one "already advised" flag would silently swallow the second
|
|
18
|
+
* diagnosis, which is the failure mode this split exists to prevent.
|
|
19
|
+
*
|
|
20
|
+
* Three counters, three meanings — keeping them apart is what stops the plugin
|
|
21
|
+
* from feeding on itself:
|
|
22
|
+
*
|
|
23
|
+
* - `observations` counts *failures of this environment in this family*, i.e.
|
|
13
24
|
* tool results the environment itself produced. A call this plugin denied is
|
|
14
|
-
* not one of them, even though its denial text quotes the
|
|
25
|
+
* not one of them, even though its denial text quotes the producer's line.
|
|
15
26
|
* - `failingKeys` holds the call identities (tool + canonical arguments) that
|
|
16
27
|
* have already failed this way. The fail-fast half may only refuse a call it
|
|
17
28
|
* has *watched fail* — never a call it merely recognizes as similar.
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
20
|
-
* succeeds (see `observeSuccess`), not carried for the whole session.
|
|
29
|
+
* - `denials` is spent per *episode*: it is re-armed when a watched call finally
|
|
30
|
+
* succeeds (see `observeSuccess`), not carried for the whole session.
|
|
21
31
|
*
|
|
22
32
|
* @module
|
|
23
33
|
*/
|
|
24
|
-
import type {
|
|
25
|
-
/** Everything the plugin remembers about one agent. */
|
|
26
|
-
export interface
|
|
27
|
-
/**
|
|
34
|
+
import type { FailureFamily, RecognizedFailure } from './signature.js';
|
|
35
|
+
/** Everything the plugin remembers about one agent in one failure family. */
|
|
36
|
+
export interface FamilyState {
|
|
37
|
+
/** Failures of this family observed for this agent. */
|
|
28
38
|
observations: number;
|
|
29
39
|
/** The most recent failure, for the denial text. */
|
|
30
|
-
last:
|
|
40
|
+
last: RecognizedFailure;
|
|
31
41
|
/** Identity keys of the calls that failed this way. */
|
|
32
42
|
failingKeys: Set<string>;
|
|
33
43
|
/** Denials already spent. */
|
|
34
44
|
denials: number;
|
|
35
|
-
/** Whether
|
|
45
|
+
/** Whether this family's durable advisory has been delivered for this agent. */
|
|
36
46
|
advised: boolean;
|
|
37
47
|
}
|
|
48
|
+
/** Everything the plugin remembers about one agent. */
|
|
49
|
+
export interface AgentState {
|
|
50
|
+
/** Per-family bookkeeping; a family appears only once it has been observed. */
|
|
51
|
+
readonly families: Readonly<Partial<Record<FailureFamily, FamilyState>>>;
|
|
52
|
+
/**
|
|
53
|
+
* Whether a recognized failure was withheld from the model and the host has
|
|
54
|
+
* already accounted for it once. Withholding is a real decision — the mode
|
|
55
|
+
* was not confining, or could not be resolved — and a decision the transcript
|
|
56
|
+
* cannot show must be visible somewhere, or "this is not the sandbox" and "I
|
|
57
|
+
* could not tell" read the same from the outside.
|
|
58
|
+
*/
|
|
59
|
+
readonly withheld: boolean;
|
|
60
|
+
}
|
|
61
|
+
/** The state of an agent this plugin has never observed. */
|
|
62
|
+
export declare function emptyState(): AgentState;
|
|
38
63
|
/**
|
|
39
64
|
* Canonicalize a parsed argument value into a stable string.
|
|
40
65
|
*
|
|
@@ -56,29 +81,55 @@ export declare function canonicalize(value: unknown): string;
|
|
|
56
81
|
*/
|
|
57
82
|
export declare function callKey(name: string, args: unknown): string;
|
|
58
83
|
/**
|
|
59
|
-
* Record one observed
|
|
84
|
+
* Record one observed failure in one family.
|
|
60
85
|
* @param state - the agent's current state, or undefined on first sight.
|
|
86
|
+
* @param family - the family the failure belongs to.
|
|
61
87
|
* @param failure - the recognized failure.
|
|
62
88
|
* @param key - the identity of the call that failed.
|
|
63
89
|
* @returns the updated state.
|
|
64
90
|
*/
|
|
65
|
-
export declare function observe(state: AgentState | undefined, failure:
|
|
91
|
+
export declare function observe(state: AgentState | undefined, family: FailureFamily, failure: RecognizedFailure, key: string): AgentState;
|
|
92
|
+
/**
|
|
93
|
+
* Whether this family's durable advisory has already been delivered.
|
|
94
|
+
* @param state - the agent's current state, or undefined.
|
|
95
|
+
* @param family - the family in question.
|
|
96
|
+
* @returns true when the advice is already in the session.
|
|
97
|
+
*/
|
|
98
|
+
export declare function advisedOf(state: AgentState | undefined, family: FailureFamily): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Mark one family's durable advisory as delivered.
|
|
101
|
+
* @param state - the agent's current state.
|
|
102
|
+
* @param family - the family that was advised.
|
|
103
|
+
* @returns the updated state.
|
|
104
|
+
*/
|
|
105
|
+
export declare function recordAdvice(state: AgentState, family: FailureFamily): AgentState;
|
|
106
|
+
/**
|
|
107
|
+
* Record that a recognized failure was withheld from the model.
|
|
108
|
+
* @param state - the agent's current state, or undefined on first sight.
|
|
109
|
+
* @returns the updated state.
|
|
110
|
+
*/
|
|
111
|
+
export declare function recordWithheld(state: AgentState | undefined): AgentState;
|
|
66
112
|
/**
|
|
67
113
|
* Record that a call carrying the same identity as a previously failing one
|
|
68
|
-
* succeeded
|
|
69
|
-
* stops justifying a denial — and is dropped rather than kept, so a later
|
|
70
|
-
* failure re-earns it.
|
|
114
|
+
* succeeded — in **every** family that was watching that identity.
|
|
71
115
|
*
|
|
72
|
-
* The
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
116
|
+
* The environment worked at least once for that call, so the entry stops
|
|
117
|
+
* justifying a denial — and is dropped rather than kept, so a later failure
|
|
118
|
+
* re-earns it. A success is evidence about the environment, not about one
|
|
119
|
+
* diagnosis: the same call cannot have started working for one family's reason
|
|
120
|
+
* and not the other's.
|
|
121
|
+
*
|
|
122
|
+
* Each affected family's denial budget is re-armed at the same moment, and only
|
|
123
|
+
* then. Measured consequence: without it the budget is per agent for the whole
|
|
124
|
+
* session, which makes the entry above unobservable — past `maxDenials` this
|
|
125
|
+
* plugin refuses nothing ever again, so clearing the key would change no
|
|
126
|
+
* decision. With it, the bound reads as "at most `maxDenials` refusals per
|
|
127
|
+
* episode of brokenness": an environment that breaks, is repaired and breaks
|
|
128
|
+
* again may be refused again, while a session can always make progress by
|
|
129
|
+
* spending the budget.
|
|
79
130
|
* @param state - the agent's current state.
|
|
80
131
|
* @param key - the identity of the call that just succeeded.
|
|
81
|
-
* @returns the updated state, unchanged when
|
|
132
|
+
* @returns the updated state, unchanged when no family was watching the key.
|
|
82
133
|
*/
|
|
83
134
|
export declare function observeSuccess(state: AgentState, key: string): AgentState;
|
|
84
135
|
/**
|
|
@@ -89,22 +140,21 @@ export declare function observeSuccess(state: AgentState, key: string): AgentSta
|
|
|
89
140
|
* fail. The second condition is what keeps the fail-fast half from blocking a
|
|
90
141
|
* workaround: a different command, or the same command under a different policy
|
|
91
142
|
* after the user changed configuration, has no key here.
|
|
143
|
+
*
|
|
144
|
+
* Only the `acl-provisioning` family ever reaches this question; see the
|
|
145
|
+
* `denialText` doc for why the blocking half does not extend to `pty-startup`.
|
|
92
146
|
* @param state - the agent's current state, or undefined.
|
|
147
|
+
* @param family - the family whose threshold is being asked about.
|
|
93
148
|
* @param key - the identity of the call about to dispatch.
|
|
94
149
|
* @param enforceAfter - the configured threshold; 0 disables the half entirely.
|
|
95
150
|
* @param maxDenials - the configured denial budget.
|
|
96
151
|
* @returns whether to deny.
|
|
97
152
|
*/
|
|
98
|
-
export declare function shouldDeny(state: AgentState | undefined, key: string, enforceAfter: number, maxDenials: number): boolean;
|
|
153
|
+
export declare function shouldDeny(state: AgentState | undefined, family: FailureFamily, key: string, enforceAfter: number, maxDenials: number): boolean;
|
|
99
154
|
/**
|
|
100
155
|
* Spend one denial.
|
|
101
156
|
* @param state - the agent's current state.
|
|
157
|
+
* @param family - the family being denied.
|
|
102
158
|
* @returns the updated state.
|
|
103
159
|
*/
|
|
104
|
-
export declare function recordDenial(state: AgentState): AgentState;
|
|
105
|
-
/**
|
|
106
|
-
* Mark the durable advisory as delivered.
|
|
107
|
-
* @param state - the agent's current state.
|
|
108
|
-
* @returns the updated state.
|
|
109
|
-
*/
|
|
110
|
-
export declare function recordAdvice(state: AgentState): AgentState;
|
|
160
|
+
export declare function recordDenial(state: AgentState, family: FailureFamily): AgentState;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@argszero/cordis-plugin-sandbox-grant-advisor",
|
|
3
|
-
"description": "Turns
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Turns two sandbox environment failures that name neither their cause nor a remedy into a diagnosis with a path forward. Family 1, the Windows workspace ACL: three reports (#7538, #7622, #7646) of one signature — every sandboxed command fails before it runs with `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)`, because the merged DACL + mandatory-label write needs WRITE_OWNER on the directory (an object right the caller can self-grant), not SeSecurityPrivilege and not elevation; the host grant is materialized lazily and caches nothing on the failure path, so the same failure repeats per command. Family 2, the persistent shell (#7638): with the `minimal` preset under a confining sandbox mode every shell call dies instantly with `PTY shell exited during startup` because the terminal backend cannot create the pseudo-console inside the sandbox, retrying never helps, and `minimal` mounts no fallback shell tool. The plugin observes the public `tools/post-execute` waterfall, classifies both signatures narrowly (only the two `...NamedSecurityInfoW` operations; the PTY message matched on a whole line, never as a substring, and only under a mode the policy resolver reports as confining), and attaches ONE durable user-role advisory per agent per family through `additionalContexts`. The ACL advisory names the missing right, the discriminator, and the unelevated `icacls` fix; the PTY advisory names the failing combination, states the resolved mode, tells the model to stop rather than retry, and hands the user-side preset choice over — it never names a shell tool the failing composition does not mount. An optional, off-by-default `enforceAfter` refuses an identical ACL call this plugin has watched fail, bounded by `maxDenials`; the blocking half is ACL-only by design. It never edits an ACL, never elevates, and never changes a preset or a mode, and it complements repeat-guard-escalation, which keys on call identity rather than on the environment signature.",
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"pretest": "tsc",
|
|
68
68
|
"test": "node --test \"test/*.spec.mjs\"",
|
|
69
69
|
"test:probe-lines": "node scripts/probe-lines.mjs",
|
|
70
|
-
"prepublishOnly": "tsc"
|
|
70
|
+
"prepublishOnly": "tsc",
|
|
71
|
+
"test:inject": "node scripts/inject-defects.mjs"
|
|
71
72
|
},
|
|
72
73
|
"engines": {
|
|
73
74
|
"node": "^22.19 || >=24"
|