@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.
@@ -1,5 +1,8 @@
1
1
  /**
2
- * Recognize the Windows ACL provisioning failure that has no path forward.
2
+ * Recognize the two environment failures this plugin explains, and refuse
3
+ * everything else.
4
+ *
5
+ * ## The ACL provisioning failure (`acl-provisioning`)
3
6
  *
4
7
  * The harness's Windows sandbox provisions a workspace by writing the
5
8
  * directory's DACL and its mandatory-integrity label in **one**
@@ -10,7 +13,7 @@
10
13
  * it. Every sandboxed command then fails the same way, forever, because the
11
14
  * grant is materialized lazily and nothing is cached on the failure path.
12
15
  *
13
- * Recognizing the string is therefore the whole job of this module, and the
16
+ * Recognizing the string is therefore the whole job of this half, and the
14
17
  * recognition is deliberately narrow:
15
18
  *
16
19
  * - **Only the two `...NamedSecurityInfoW` operations are classified.** Their
@@ -27,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
- * Classify one failure message.
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 a `Win32Error` would have produced.
127
+ * @returns the message text the producing layer would have produced.
65
128
  */
66
- export declare function failureLine(failure: ProvisioningFailure): string;
129
+ export declare function failureLine(failure: RecognizedFailure): string;
@@ -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
- * Two counters, two meanings — keeping them apart is what stops the plugin from
10
- * feeding on itself:
9
+ * ## One record per family
11
10
  *
12
- * - `observations` counts *provisioning failures of this environment*, i.e.
11
+ * This plugin now recognizes two unrelated environment failures — a workspace
12
+ * that cannot be provisioned (`acl-provisioning`) and a persistent shell that
13
+ * cannot start (`pty-startup`). They are different diagnoses with different
14
+ * remedies, so their bookkeeping is kept apart under one agent
15
+ * ({@link AgentState.families}): an agent that hits both is told about both,
16
+ * and an agent that has already been told about one is still told about the
17
+ * other. Sharing one "already advised" flag would silently swallow the second
18
+ * diagnosis, which is the failure mode this split exists to prevent.
19
+ *
20
+ * Three counters, three meanings — keeping them apart is what stops the plugin
21
+ * from feeding on itself:
22
+ *
23
+ * - `observations` counts *failures of this environment in this family*, i.e.
13
24
  * tool results the environment itself produced. A call this plugin denied is
14
- * not one of them, even though its denial text quotes the Win32 line.
25
+ * not one of them, even though its denial text quotes the producer's line.
15
26
  * - `failingKeys` holds the call identities (tool + canonical arguments) that
16
27
  * have already failed this way. The fail-fast half may only refuse a call it
17
28
  * has *watched fail* — never a call it merely recognizes as similar.
18
- *
19
- * `denials` is spent per *episode*: it is re-armed when a watched call finally
20
- * succeeds (see `observeSuccess`), not carried for the whole session.
29
+ * - `denials` is spent per *episode*: it is re-armed when a watched call finally
30
+ * succeeds (see `observeSuccess`), not carried for the whole session.
21
31
  *
22
32
  * @module
23
33
  */
24
- import type { ProvisioningFailure } from './signature.js';
25
- /** Everything the plugin remembers about one agent. */
26
- export interface AgentState {
27
- /** Provisioning failures observed for this agent. */
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: ProvisioningFailure;
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 the durable advisory has been delivered for this agent. */
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 provisioning failure.
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: ProvisioningFailure, key: string): AgentState;
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. The environment worked at least once for that call, so the entry
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 denial budget is re-armed at the same moment, and only then. Measured
73
- * consequence: without it the budget is per agent for the whole session, which
74
- * makes the entry above unobservable — past `maxDenials` this plugin refuses
75
- * nothing ever again, so clearing the key would change no decision. With it the
76
- * bound reads as "at most `maxDenials` refusals per episode of brokenness": an
77
- * environment that breaks, is repaired and breaks again may be refused again,
78
- * while a session can always make progress by spending the budget.
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 the key was not failing.
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 the Windows sandbox's ACL provisioning failure into a diagnosis with a path forward. Three reports (#7538, #7622, #7646) describe one signature — every sandboxed command fails before it runs with `SetNamedSecurityInfoW failed (Win32 5): grantWrite(<workspace>)` — and the error names neither the missing right nor a remedy, so the loop burns tokens and sessions escape into danger-full-access. The host-side grant is materialized lazily and caches nothing on the failure path, so the same failure repeats per command. This plugin observes the public `tools/post-execute` waterfall, classifies that signature (only the two `...NamedSecurityInfoW` operations, keeping the Win32 code and the producer's own detail verbatim), and attaches ONE durable user-role advisory per agent through `additionalContexts`: the missing right is WRITE_OWNER on the directory (an object right the caller can self-grant), not SeSecurityPrivilege and not elevation; the directory's current ACL is the discriminator; the fix is one unelevated `icacls` line. An optional, off-by-default `enforceAfter` refuses an identical call this plugin has watched fail, bounded by `maxDenials`. It never edits an ACL and never elevates, and it complements repeat-guard-escalation, which keys on call identity rather than on the environment signature.",
4
- "version": "0.1.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"