@argszero/cordis-plugin-sandbox-grant-advisor 0.1.0 → 0.3.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,23 +1,45 @@
1
1
  /**
2
- * `sandbox-grant-advisor`: turn a Windows ACL provisioning failure that has no
3
- * path forward into a diagnosis the model — and the user reading the
4
- * transcript — can act on.
2
+ * `sandbox-grant-advisor`: turn an environment failure that has no path forward
3
+ * into a diagnosis the model — and the user reading the transcript — can act on.
5
4
  *
6
- * Three reports of one signature (`#7538`, `#7622`, `#7646`) describe the same
7
- * shape: the host-side write grant for a sandboxed workspace cannot be applied,
8
- * every sandboxed command then fails identically **before it runs**, and the
9
- * error text is a bare Win32 line:
5
+ * ## The two failures it recognizes
6
+ *
7
+ * **Workspace provisioning (Windows ACL).** Four reports of one signature
8
+ * (`#7538`, `#7622`, `#7646`, `#7720`) describe the same shape: the host-side write grant
9
+ * for a sandboxed workspace cannot be applied, every sandboxed command then
10
+ * fails identically **before it runs**, and the error text is a bare Win32 line:
10
11
  *
11
12
  * SetNamedSecurityInfoW failed (Win32 5): grantWrite(D:\ws)
12
13
  *
13
14
  * The grant is materialized lazily on the first confined call and nothing is
14
15
  * cached when it throws, so the failure repeats per command rather than once
15
16
  * (850 calls / 39 sessions in `#7622`; 52,588 output tokens with no output in
16
- * `#7538`). The `workspace-write` policy is simply unusable in such a
17
- * workspace, and the remedy the backend documents — the directory must grant
18
- * the caller `WRITE_OWNER` — never reaches the user, so sessions escape into
17
+ * `#7538`). The remedy the backend documents — the directory must grant the
18
+ * caller `WRITE_OWNER` — never reaches the user, so sessions escape into
19
19
  * `danger-full-access` or die on the model's output cap.
20
20
  *
21
+ * `#7720` sharpens where this lands: because the grant is materialized at
22
+ * sandbox *initialization*, that failure takes **every** shell tool with it, not
23
+ * one operation — the reporter could not run `netstat` or `icacls` to diagnose
24
+ * the failure they were looking at. It also contributes the two remedies that
25
+ * look right and are not (`takeown /R /D Y`, `icacls /reset /T /C`), which the
26
+ * ACL advisory now names along with the reason each fails.
27
+ *
28
+ * **Persistent shell startup (#7638).** With the `minimal` preset on Windows the
29
+ * only shell tool is a persistent PTY (`dsh-terminal-bash` +
30
+ * `dsh-tool-pwsh-persistent`), and under a *confining* sandbox mode every call
31
+ * fails instantly with
32
+ *
33
+ * PTY shell exited during startup
34
+ *
35
+ * — the backend cannot create the pseudo-console inside the sandbox, so the
36
+ * child exits before its first prompt. Retrying never helps, the message points
37
+ * at no cause, and because `minimal` mounts no fallback shell tool the session
38
+ * has no command execution left at all. The reporter's own three-arm control
39
+ * makes the sandbox mode the discriminator: minimal × confining fails, minimal ×
40
+ * `danger-full-access` succeeds, `standard` (one-shot shell) × confining
41
+ * succeeds.
42
+ *
21
43
  * ## Where it acts, and why there
22
44
  *
23
45
  * One listener on the public `tools/post-execute` waterfall
@@ -28,44 +50,66 @@
28
50
  * to the model in the same step (`PostToolDecision`'s `additionalContexts`,
29
51
  * a durable user-role message).
30
52
  *
31
- * `ctx.sandbox.confine(argv, policy, signal)` sees the failure too, and cannot
32
- * do this: its signature carries no agent, so a wrapper could detect the
33
- * condition and never deliver a word about it to the session that is stuck.
53
+ * `ctx.sandbox.confine(argv, policy, signal)` sees the confinement failure too,
54
+ * and cannot do this: its signature carries no agent, so a wrapper could detect
55
+ * the condition and never deliver a word about it to the session that is stuck.
56
+ *
57
+ * The PTY family needs one fact the failure text does not carry — the effective
58
+ * sandbox mode — and takes it from `ctx.sandboxPolicy.resolve({ session })`:
59
+ * the same resolver the terminal layer calls before spawning, with the same
60
+ * session. See `src/mode.ts` for why that lookup is guarded rather than
61
+ * imported, and what happens when it cannot answer.
34
62
  *
35
63
  * ## What it does
36
64
  *
37
- * 1. **One durable advisory per agent.** On the first recognized provisioning
38
- * failure, the failing tool result is enriched with a user-role notice that
39
- * names the missing right (`WRITE_OWNER` on the directory, not
40
- * `SeSecurityPrivilege`), gives the unelevated one-line `icacls` remedy, and
41
- * gives the discriminator that separates a Modify-only directory from a
42
- * wrong prerequisite. Attached through `additionalContexts`, so the model
43
- * sees it beside the failure rather than only in a log the model never reads.
44
- * 2. **An optional bounded fail-fast.** With `enforceAfter` set, a call this
45
- * plugin has *watched fail* this way is refused at `tools/pre-execute` once
46
- * the environment has failed at least that many times. It is off by default:
47
- * the useful signal here is the diagnosis, and a plugin that blocks command
48
- * execution for a reason it merely recognizes is a risk, not a feature. See
49
- * the README for why the blocking half is deliberately narrow.
65
+ * 1. **One durable advisory per agent, per family.** On the first recognized
66
+ * failure of a family, the failing tool result is enriched with a user-role
67
+ * notice. For the ACL family it names the missing right (`WRITE_OWNER` on the
68
+ * directory, not `SeSecurityPrivilege`), gives the unelevated one-line
69
+ * `icacls` remedy, gives the discriminator that separates a Modify-only
70
+ * directory from a wrong prerequisite, and names the two remedies that look
71
+ * right and are not (`takeown`, `icacls /reset`), each with its reason. For the PTY family it names the
72
+ * combination that fails (persistent PTY × a confining mode), states the
73
+ * resolved mode, says plainly that no command can fix it, and hands the
74
+ * user-side preset choice over. Both ride `additionalContexts`, so the model
75
+ * sees the diagnosis beside the failure rather than only in a log it never
76
+ * reads.
77
+ * 2. **A bounded fail-fast, ACL family only.** With `enforceAfter` set, a call
78
+ * this plugin has *watched fail* this way is refused at `tools/pre-execute`
79
+ * once the environment has failed at least that many times. It is off by
80
+ * default: the useful signal here is the diagnosis, and a plugin that blocks
81
+ * command execution for a reason it merely recognizes is a risk, not a
82
+ * feature. See the README for why the blocking half is deliberately narrow
83
+ * and why it does not cover the PTY family.
84
+ * 3. **A disclosure when it withholds.** The PTY advisory is only sent when the
85
+ * resolved mode actually confines; if the mode is not confining, or cannot be
86
+ * resolved at all, the failure is left exactly as it was **and the host log
87
+ * says so once**. Silence alone would make "the sandbox is not the cause" and
88
+ * "this plugin could not tell" indistinguishable from the outside.
50
89
  *
51
90
  * ## Honest boundaries
52
91
  *
53
92
  * - **The Windows path cannot be witnessed on macOS**, where this plugin was
54
93
  * built and tested. What is tested is the decision layer: classification,
55
- * once-per-agent delivery, the fail-fast budget, and the wiring to the real
56
- * `ToolRuntime` — against synthetic results carrying the producer's exact
57
- * error shape, with the format taken from
58
- * `packages/subprocess/win32-process/src/errors.ts`.
94
+ * once-per-agent-per-family delivery, the sandbox-mode gate and its
95
+ * fail-closed behaviour, the fail-fast budget, and the wiring to the real
96
+ * `ToolRuntime` — against synthetic results carrying the producers' exact
97
+ * error shapes, with the formats taken from
98
+ * `packages/subprocess/win32-process/src/errors.ts` and
99
+ * `packages/terminal/terminal-bash/src/{index,session}.ts`.
59
100
  * - **It does not repair anything.** No ACL is written, no privilege is
60
- * requested, nothing is elevated: the `icacls` line is the user's to run.
101
+ * requested, nothing is elevated, no preset is installed and no mode is
102
+ * changed: both remedies are the user's to apply.
61
103
  * - **It complements, rather than replaces, `repeat-guard-escalation`.** That
62
104
  * guard keys on *call identity* (identical arguments retried); this one keys
63
105
  * on the *environment signature*, which is how several different commands can
64
106
  * share one cause. They can be mounted together.
65
- * - **The real fix is upstream**: the failure should name the outstanding
66
- * condition at the site that knows it (`grantWrite` computes
107
+ * - **The real fix is upstream**, in both families: the ACL failure should name
108
+ * the outstanding condition at the site that knows it (`grantWrite` computes
67
109
  * `hasExactGrant`/`hasExactDeny`/`hasExactLabel` and discards which was
68
- * false). This plugin is the stopgap.
110
+ * false), and the PTY startup path should either report "this sandbox mode is
111
+ * incompatible with the PTY backend" or fall back to a one-shot shell. This
112
+ * plugin is the stopgap.
69
113
  *
70
114
  * @module @argszero/cordis-plugin-sandbox-grant-advisor
71
115
  */
@@ -87,12 +131,21 @@ export declare const SOURCE_KIND = "sandbox-grant-advisor";
87
131
  export declare const DEFAULT_ENFORCE_AFTER = 0;
88
132
  /** Default denial budget once the blocking half is enabled. */
89
133
  export declare const DEFAULT_MAX_DENIALS = 2;
134
+ /**
135
+ * The family the optional blocking half applies to.
136
+ *
137
+ * The ACL remedy is a command the user can run while the session continues; the
138
+ * PTY remedy is a preset swap between sessions. Refusing calls is only useful
139
+ * in the first case — see `denialText` in `src/advice.ts`.
140
+ */
141
+ export declare const ENFORCED_FAMILY = "acl-provisioning";
90
142
  /** Configures what is watched and whether the blocking half runs. */
91
143
  export interface Config {
92
144
  /**
93
- * Provisioning failures after which an identical, already-failing call is
145
+ * ACL provisioning failures after which an identical, already-failing call is
94
146
  * denied before dispatch. `0` (the default) disables the half entirely; the
95
- * advisory half is unaffected and always on.
147
+ * advisory half is unaffected and always on. The blocking half does not apply
148
+ * to the persistent-shell family.
96
149
  */
97
150
  enforceAfter?: number;
98
151
  /**
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Which sandbox mode a call ran under, and whether that mode confines.
3
+ *
4
+ * The persistent-shell diagnosis is only true under a **confining** mode. The
5
+ * terminal backend hands the shell argv straight through when the resolved mode
6
+ * is `danger-full-access` and confines it otherwise
7
+ * (`packages/terminal/terminal-bash/src/index.ts`:
8
+ * `if (policy.mode === 'danger-full-access') return argv`), so the same
9
+ * `PTY shell exited during startup` under `danger-full-access` is a different
10
+ * story — a broken or missing shell — and this plugin must stay silent about it
11
+ * rather than assert a sandbox cause it cannot support. The report that frames
12
+ * this family (#7638) says the same thing from the other side: its author's
13
+ * three-arm control shows the mode is the discriminator (minimal × confining
14
+ * fails, minimal × `danger-full-access` succeeds, standard × confining
15
+ * succeeds).
16
+ *
17
+ * The answer is taken from `ctx.sandboxPolicy.resolve({ session })` — the same
18
+ * resolver the terminal layer itself calls before spawning, with the same
19
+ * session — so what is quoted in the advisory is the policy that actually
20
+ * governed the failing call, not a guess reconstructed from configuration.
21
+ *
22
+ * ## Why this is a guarded lookup instead of an import
23
+ *
24
+ * `@deepseek-ai/dsh-sandbox-policy` is **optional** in this plugin's world: a
25
+ * composition may simply not mount the service, and this plugin must degrade to
26
+ * silence rather than fail to load. Two consequences shape this module:
27
+ *
28
+ * - A declared peer dependency is a claim about versions, and this package's
29
+ * packaging guard refuses both an import that is not declared and a
30
+ * declaration that is not imported. A *type-only* import would therefore turn
31
+ * an optional integration into a mandatory claim on every line the peer range
32
+ * admits — and a range that admits a line nobody ran is exactly the defect
33
+ * that guard exists to prevent.
34
+ * - What is left is the consumer-side capability guard: look the service up,
35
+ * check the shape of the answer instead of trusting it, and **fail closed**
36
+ * (`{ ok: false }`): an unresolvable mode withholds the advisory, and the
37
+ * caller discloses that withholding on the host side. An unresolvable mode is
38
+ * emphatically *not* an invitation to fall back to the deployment default —
39
+ * a session that overrode its mode to `danger-full-access` would then be
40
+ * diagnosed as if it were confined.
41
+ *
42
+ * The call site this module depends on is stable across every line the peer
43
+ * range claims: `resolve(request?: SandboxPolicyRequest): SandboxExecutionPolicy`
44
+ * is declared at the same position of `lib/types/index.d.ts` in every published
45
+ * build from `0.1.2-rc.1` to `0.1.7-rc.1`, and `Agent.session` is present on
46
+ * the same span of `@deepseek-ai/dsh-agent`.
47
+ *
48
+ * @module
49
+ */
50
+ import type { Context } from '@deepseek-ai/cordis';
51
+ import type { Agent } from '@deepseek-ai/dsh-agent';
52
+ /** The three modes the harness resolves. */
53
+ export type SandboxModeName = 'read-only' | 'workspace-write' | 'danger-full-access';
54
+ /**
55
+ * Whether a mode confines the process it is asked to spawn.
56
+ * @param mode - the resolved mode.
57
+ * @returns true for every mode except `danger-full-access`.
58
+ */
59
+ export declare function confines(mode: SandboxModeName): boolean;
60
+ /** The outcome of resolving one agent's effective sandbox mode. */
61
+ export type ModeResolution =
62
+ /**
63
+ * The mode the failing call ran under. `danger-full-access` is reported too:
64
+ * it is a real answer, and the caller's job (not this module's) is to decide
65
+ * that a non-confining mode is not this plugin's story.
66
+ */
67
+ {
68
+ readonly ok: true;
69
+ readonly mode: SandboxModeName;
70
+ }
71
+ /** No answer was available, and why — the host side says so out loud. */
72
+ | {
73
+ readonly ok: false;
74
+ readonly withheld: string;
75
+ };
76
+ /**
77
+ * Resolve the effective sandbox mode for one agent's call.
78
+ *
79
+ * The service is looked up through `ctx.get` — the documented optional lookup —
80
+ * and the resolver is invoked with the agent's own session, so a session that
81
+ * logged a `sandbox/mode` override is answered with that override rather than
82
+ * with the deployment default.
83
+ * @param ctx - the plugin's context.
84
+ * @param agent - the agent whose call failed.
85
+ * @returns the mode, or the reason it could not be resolved.
86
+ */
87
+ export declare function resolveSandboxMode(ctx: Context, agent: Agent): ModeResolution;
@@ -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: four reports (#7538, #7622, #7646, #7720) 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, the unelevated `icacls` fix, and the two remedies that look right and are not (`takeown`, `icacls /reset`), each with the reason it fails; 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.3.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"