@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.
- package/README.md +226 -78
- package/cordis.patch.yml +18 -0
- package/lib/advice.js +177 -18
- package/lib/index.js +216 -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 +95 -21
- package/lib/types/index.d.ts +89 -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/mode.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
/**
|
|
51
|
+
* Whether a mode confines the process it is asked to spawn.
|
|
52
|
+
* @param mode - the resolved mode.
|
|
53
|
+
* @returns true for every mode except `danger-full-access`.
|
|
54
|
+
*/
|
|
55
|
+
export function confines(mode) {
|
|
56
|
+
return mode !== 'danger-full-access';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Narrow an optional lookup result to the resolver shape.
|
|
60
|
+
* @param value - whatever `ctx.get('sandboxPolicy')` returned.
|
|
61
|
+
* @returns the same object, typed as the resolver, or undefined.
|
|
62
|
+
*/
|
|
63
|
+
function asPolicy(value) {
|
|
64
|
+
if (value === null || typeof value !== 'object')
|
|
65
|
+
return undefined;
|
|
66
|
+
if (typeof value.resolve !== 'function')
|
|
67
|
+
return undefined;
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The agent's live session, read defensively.
|
|
72
|
+
*
|
|
73
|
+
* `Agent.session` is declared by `@deepseek-ai/dsh-agent`'s runtime face and is
|
|
74
|
+
* present on every claimed line; it is read as `unknown` here anyway, because a
|
|
75
|
+
* gate that trusts the shape of its input is the same gate that reports a cause
|
|
76
|
+
* from the wrong world when the input is not what it expected.
|
|
77
|
+
* @param agent - the agent whose call failed.
|
|
78
|
+
* @returns the session object, or undefined.
|
|
79
|
+
*/
|
|
80
|
+
function agentSession(agent) {
|
|
81
|
+
const session = agent.session;
|
|
82
|
+
return session !== null && typeof session === 'object' ? session : undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The mode inside a resolved policy, if it is one this harness defines.
|
|
86
|
+
* @param resolved - the resolver's return value.
|
|
87
|
+
* @returns the mode, or undefined when the value is not a recognizable policy.
|
|
88
|
+
*/
|
|
89
|
+
function recognizedMode(resolved) {
|
|
90
|
+
if (resolved === null || typeof resolved !== 'object')
|
|
91
|
+
return undefined;
|
|
92
|
+
const mode = resolved.mode;
|
|
93
|
+
return mode === 'read-only' || mode === 'workspace-write' || mode === 'danger-full-access'
|
|
94
|
+
? mode
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the effective sandbox mode for one agent's call.
|
|
99
|
+
*
|
|
100
|
+
* The service is looked up through `ctx.get` — the documented optional lookup —
|
|
101
|
+
* and the resolver is invoked with the agent's own session, so a session that
|
|
102
|
+
* logged a `sandbox/mode` override is answered with that override rather than
|
|
103
|
+
* with the deployment default.
|
|
104
|
+
* @param ctx - the plugin's context.
|
|
105
|
+
* @param agent - the agent whose call failed.
|
|
106
|
+
* @returns the mode, or the reason it could not be resolved.
|
|
107
|
+
*/
|
|
108
|
+
export function resolveSandboxMode(ctx, agent) {
|
|
109
|
+
const policy = asPolicy(ctx.get('sandboxPolicy'));
|
|
110
|
+
if (policy === undefined) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
withheld: 'no `sandboxPolicy` service is mounted in this composition, so the effective mode is unknown',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const session = agentSession(agent);
|
|
117
|
+
if (session === undefined) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
withheld: 'the agent exposes no session, and the policy must be resolved from it rather than from the deployment default',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let resolved;
|
|
124
|
+
try {
|
|
125
|
+
resolved = policy.resolve({ session });
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
return { ok: false, withheld: `\`sandboxPolicy.resolve\` threw (${String(error)})` };
|
|
129
|
+
}
|
|
130
|
+
const mode = recognizedMode(resolved);
|
|
131
|
+
if (mode === undefined) {
|
|
132
|
+
return { ok: false, withheld: '`sandboxPolicy.resolve` returned a value without a recognizable mode' };
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, mode };
|
|
135
|
+
}
|
package/lib/signature.js
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,8 +30,32 @@
|
|
|
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
|
*/
|
|
57
|
+
/** The exact text `dsh-terminal-bash` throws when the shell exits during startup. */
|
|
58
|
+
export const PTY_STARTUP_EXIT = 'PTY shell exited during startup';
|
|
32
59
|
/**
|
|
33
60
|
* The producer's format is fixed by `Win32Error`
|
|
34
61
|
* (`packages/subprocess/win32-process/src/errors.ts`):
|
|
@@ -52,7 +79,7 @@ function splitDetail(detail) {
|
|
|
52
79
|
return { label, path };
|
|
53
80
|
}
|
|
54
81
|
/**
|
|
55
|
-
* Classify one failure message.
|
|
82
|
+
* Classify one failure message against the ACL family.
|
|
56
83
|
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
57
84
|
* @returns the recognized failure, or undefined when this is not a provisioning failure.
|
|
58
85
|
*/
|
|
@@ -68,14 +95,38 @@ export function classifyProvisioningFailure(message) {
|
|
|
68
95
|
const klass = api === 'GetNamedSecurityInfoW'
|
|
69
96
|
? 'read-denied'
|
|
70
97
|
: code === ACCESS_DENIED ? 'apply-denied' : 'apply-other';
|
|
71
|
-
return { klass, api, win32Code: code, detail, ...splitDetail(detail) };
|
|
98
|
+
return { family: 'acl-provisioning', klass, api, win32Code: code, detail, ...splitDetail(detail) };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Classify one failure message as the persistent-shell startup failure.
|
|
102
|
+
*
|
|
103
|
+
* Recognition is by **whole line**, because the producer's message is a bare
|
|
104
|
+
* sentence with no fields of its own: a line equal to
|
|
105
|
+
* {@link PTY_STARTUP_EXIT} — with only the `Error: ` envelope a tool result adds
|
|
106
|
+
* in front of it — is the producer. A longer line that happens to contain the
|
|
107
|
+
* sentence is something quoting it (a transcript, a log the failing command
|
|
108
|
+
* printed, a pasted issue body), and advising about the sandbox there would be
|
|
109
|
+
* advice about the wrong thing.
|
|
110
|
+
* @param message - the failure text, from the result's `error.message` or its rendered content.
|
|
111
|
+
* @returns the recognized failure, or undefined when this is not one.
|
|
112
|
+
*/
|
|
113
|
+
export function classifyPtyStartupFailure(message) {
|
|
114
|
+
for (const raw of message.split('\n')) {
|
|
115
|
+
const line = raw.trim();
|
|
116
|
+
if (line === PTY_STARTUP_EXIT || line === `Error: ${PTY_STARTUP_EXIT}`) {
|
|
117
|
+
return { family: 'pty-startup', line: PTY_STARTUP_EXIT };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
72
121
|
}
|
|
73
122
|
/**
|
|
74
123
|
* The one-line failure the producer wrote, for quoting back verbatim.
|
|
75
124
|
* @param failure - a recognized failure.
|
|
76
|
-
* @returns the message text
|
|
125
|
+
* @returns the message text the producing layer would have produced.
|
|
77
126
|
*/
|
|
78
127
|
export function failureLine(failure) {
|
|
128
|
+
if (failure.family === 'pty-startup')
|
|
129
|
+
return failure.line;
|
|
79
130
|
const suffix = failure.detail.length === 0 ? '' : `: ${failure.detail}`;
|
|
80
131
|
return `${failure.api} failed (Win32 ${failure.win32Code})${suffix}`;
|
|
81
132
|
}
|
package/lib/state.js
CHANGED
|
@@ -6,21 +6,52 @@
|
|
|
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
|
*/
|
|
34
|
+
/** The state of an agent this plugin has never observed. */
|
|
35
|
+
export function emptyState() {
|
|
36
|
+
return { families: {}, withheld: false };
|
|
37
|
+
}
|
|
38
|
+
/** The record for one family, if this agent has one. */
|
|
39
|
+
function familyOf(state, family) {
|
|
40
|
+
return state?.families[family];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Replace one family's record, leaving the others (and the withheld flag) alone.
|
|
44
|
+
* @param state - the agent's current state, or undefined on first sight.
|
|
45
|
+
* @param family - the family being updated.
|
|
46
|
+
* @param record - the family's new record.
|
|
47
|
+
* @returns the updated state.
|
|
48
|
+
*/
|
|
49
|
+
function withFamily(state, family, record) {
|
|
50
|
+
return {
|
|
51
|
+
families: { ...state?.families, [family]: record },
|
|
52
|
+
withheld: state?.withheld ?? false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
24
55
|
/**
|
|
25
56
|
* Canonicalize a parsed argument value into a stable string.
|
|
26
57
|
*
|
|
@@ -63,46 +94,91 @@ export function callKey(name, args) {
|
|
|
63
94
|
return `${name}(${canonicalize(args)})`;
|
|
64
95
|
}
|
|
65
96
|
/**
|
|
66
|
-
* Record one observed
|
|
97
|
+
* Record one observed failure in one family.
|
|
67
98
|
* @param state - the agent's current state, or undefined on first sight.
|
|
99
|
+
* @param family - the family the failure belongs to.
|
|
68
100
|
* @param failure - the recognized failure.
|
|
69
101
|
* @param key - the identity of the call that failed.
|
|
70
102
|
* @returns the updated state.
|
|
71
103
|
*/
|
|
72
|
-
export function observe(state, failure, key) {
|
|
73
|
-
const
|
|
104
|
+
export function observe(state, family, failure, key) {
|
|
105
|
+
const previous = familyOf(state, family);
|
|
106
|
+
const failingKeys = new Set(previous?.failingKeys ?? []);
|
|
74
107
|
failingKeys.add(key);
|
|
75
|
-
return {
|
|
76
|
-
observations: (
|
|
108
|
+
return withFamily(state, family, {
|
|
109
|
+
observations: (previous?.observations ?? 0) + 1,
|
|
77
110
|
last: failure,
|
|
78
111
|
failingKeys,
|
|
79
|
-
denials:
|
|
80
|
-
advised:
|
|
112
|
+
denials: previous?.denials ?? 0,
|
|
113
|
+
advised: previous?.advised ?? false,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Whether this family's durable advisory has already been delivered.
|
|
118
|
+
* @param state - the agent's current state, or undefined.
|
|
119
|
+
* @param family - the family in question.
|
|
120
|
+
* @returns true when the advice is already in the session.
|
|
121
|
+
*/
|
|
122
|
+
export function advisedOf(state, family) {
|
|
123
|
+
return familyOf(state, family)?.advised ?? false;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Mark one family's durable advisory as delivered.
|
|
127
|
+
* @param state - the agent's current state.
|
|
128
|
+
* @param family - the family that was advised.
|
|
129
|
+
* @returns the updated state.
|
|
130
|
+
*/
|
|
131
|
+
export function recordAdvice(state, family) {
|
|
132
|
+
const previous = familyOf(state, family);
|
|
133
|
+
if (previous === undefined)
|
|
134
|
+
return state;
|
|
135
|
+
return withFamily(state, family, { ...previous, advised: true });
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Record that a recognized failure was withheld from the model.
|
|
139
|
+
* @param state - the agent's current state, or undefined on first sight.
|
|
140
|
+
* @returns the updated state.
|
|
141
|
+
*/
|
|
142
|
+
export function recordWithheld(state) {
|
|
143
|
+
return {
|
|
144
|
+
families: { ...state?.families },
|
|
145
|
+
withheld: true,
|
|
81
146
|
};
|
|
82
147
|
}
|
|
83
148
|
/**
|
|
84
149
|
* Record that a call carrying the same identity as a previously failing one
|
|
85
|
-
* succeeded
|
|
86
|
-
*
|
|
87
|
-
*
|
|
150
|
+
* succeeded — in **every** family that was watching that identity.
|
|
151
|
+
*
|
|
152
|
+
* The environment worked at least once for that call, so the entry stops
|
|
153
|
+
* justifying a denial — and is dropped rather than kept, so a later failure
|
|
154
|
+
* re-earns it. A success is evidence about the environment, not about one
|
|
155
|
+
* diagnosis: the same call cannot have started working for one family's reason
|
|
156
|
+
* and not the other's.
|
|
88
157
|
*
|
|
89
|
-
*
|
|
90
|
-
* consequence: without it the budget is per agent for the whole
|
|
91
|
-
* makes the entry above unobservable — past `maxDenials` this
|
|
92
|
-
* nothing ever again, so clearing the key would change no
|
|
93
|
-
* bound reads as "at most `maxDenials` refusals per
|
|
94
|
-
* environment that breaks, is repaired and breaks
|
|
95
|
-
* while a session can always make progress by
|
|
158
|
+
* Each affected family's denial budget is re-armed at the same moment, and only
|
|
159
|
+
* then. Measured consequence: without it the budget is per agent for the whole
|
|
160
|
+
* session, which makes the entry above unobservable — past `maxDenials` this
|
|
161
|
+
* plugin refuses nothing ever again, so clearing the key would change no
|
|
162
|
+
* decision. With it, the bound reads as "at most `maxDenials` refusals per
|
|
163
|
+
* episode of brokenness": an environment that breaks, is repaired and breaks
|
|
164
|
+
* again may be refused again, while a session can always make progress by
|
|
165
|
+
* spending the budget.
|
|
96
166
|
* @param state - the agent's current state.
|
|
97
167
|
* @param key - the identity of the call that just succeeded.
|
|
98
|
-
* @returns the updated state, unchanged when
|
|
168
|
+
* @returns the updated state, unchanged when no family was watching the key.
|
|
99
169
|
*/
|
|
100
170
|
export function observeSuccess(state, key) {
|
|
101
|
-
|
|
171
|
+
const watched = Object.entries(state.families)
|
|
172
|
+
.filter(([, record]) => record.failingKeys.has(key));
|
|
173
|
+
if (watched.length === 0)
|
|
102
174
|
return state;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
175
|
+
let next = state;
|
|
176
|
+
for (const [family, record] of watched) {
|
|
177
|
+
const failingKeys = new Set(record.failingKeys);
|
|
178
|
+
failingKeys.delete(key);
|
|
179
|
+
next = withFamily(next, family, { ...record, failingKeys, denials: 0 });
|
|
180
|
+
}
|
|
181
|
+
return next;
|
|
106
182
|
}
|
|
107
183
|
/**
|
|
108
184
|
* Whether a call may be refused before dispatch.
|
|
@@ -112,34 +188,35 @@ export function observeSuccess(state, key) {
|
|
|
112
188
|
* fail. The second condition is what keeps the fail-fast half from blocking a
|
|
113
189
|
* workaround: a different command, or the same command under a different policy
|
|
114
190
|
* after the user changed configuration, has no key here.
|
|
191
|
+
*
|
|
192
|
+
* Only the `acl-provisioning` family ever reaches this question; see the
|
|
193
|
+
* `denialText` doc for why the blocking half does not extend to `pty-startup`.
|
|
115
194
|
* @param state - the agent's current state, or undefined.
|
|
195
|
+
* @param family - the family whose threshold is being asked about.
|
|
116
196
|
* @param key - the identity of the call about to dispatch.
|
|
117
197
|
* @param enforceAfter - the configured threshold; 0 disables the half entirely.
|
|
118
198
|
* @param maxDenials - the configured denial budget.
|
|
119
199
|
* @returns whether to deny.
|
|
120
200
|
*/
|
|
121
|
-
export function shouldDeny(state, key, enforceAfter, maxDenials) {
|
|
122
|
-
|
|
201
|
+
export function shouldDeny(state, family, key, enforceAfter, maxDenials) {
|
|
202
|
+
const record = familyOf(state, family);
|
|
203
|
+
if (enforceAfter === 0 || record === undefined)
|
|
123
204
|
return false;
|
|
124
|
-
if (
|
|
205
|
+
if (record.observations < enforceAfter)
|
|
125
206
|
return false;
|
|
126
|
-
if (
|
|
207
|
+
if (record.denials >= maxDenials)
|
|
127
208
|
return false;
|
|
128
|
-
return
|
|
209
|
+
return record.failingKeys.has(key);
|
|
129
210
|
}
|
|
130
211
|
/**
|
|
131
212
|
* Spend one denial.
|
|
132
213
|
* @param state - the agent's current state.
|
|
214
|
+
* @param family - the family being denied.
|
|
133
215
|
* @returns the updated state.
|
|
134
216
|
*/
|
|
135
|
-
export function recordDenial(state) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
* @param state - the agent's current state.
|
|
141
|
-
* @returns the updated state.
|
|
142
|
-
*/
|
|
143
|
-
export function recordAdvice(state) {
|
|
144
|
-
return { ...state, advised: true };
|
|
217
|
+
export function recordDenial(state, family) {
|
|
218
|
+
const previous = familyOf(state, family);
|
|
219
|
+
if (previous === undefined)
|
|
220
|
+
return state;
|
|
221
|
+
return withFamily(state, family, { ...previous, denials: previous.denials + 1 });
|
|
145
222
|
}
|
package/lib/types/advice.d.ts
CHANGED
|
@@ -1,38 +1,112 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What the model — and through it the user — is told about a
|
|
3
|
-
* failure, and what is deliberately withheld.
|
|
2
|
+
* What the model — and through it the user — is told about a recognized
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
6
|
+
* by a test, one family at a time. The two families are shaped by the same two
|
|
7
|
+
* questions, and they answer them differently:
|
|
8
|
+
*
|
|
9
|
+
* - **The ACL failure** (`acl-provisioning`) *is* fixable by the caller, so its
|
|
10
|
+
* advice names the right the caller is missing and gives the command.
|
|
11
|
+
* The reported failures are `ERROR_ACCESS_DENIED` from a *merged* DACL + SACL
|
|
12
|
+
* write; the missing right is `WRITE_OWNER` on the directory — an object right
|
|
13
|
+
* the caller can grant itself with `icacls`, unelevated. It is **not**
|
|
14
|
+
* `SeSecurityPrivilege`, the token privilege the reports naturally reach for;
|
|
15
|
+
* `whoami /priv` cannot show the difference, and elevation is the wrong lever.
|
|
16
|
+
* - **The persistent-shell failure** (`pty-startup`) is *not* fixable by the
|
|
17
|
+
* caller — least of all by the model, which has no shell to run anything in.
|
|
18
|
+
* So its advice says so and stops: the remedy is a user-side preset choice,
|
|
19
|
+
* and the model's instruction is to stop retrying and use its file tools.
|
|
20
|
+
* Handing the model a command here would be advice to run something that
|
|
21
|
+
* cannot run, and naming a one-shot shell tool would be advice to call a tool
|
|
22
|
+
* the failing composition does not mount.
|
|
23
|
+
*
|
|
24
|
+
* Both give a **discriminator, not just a remedy**: applying a fix without
|
|
25
|
+
* confirming the cause teaches nothing when the fix does not work. For the ACL
|
|
26
|
+
* family that is `icacls <dir>`, looking for an ACE that names the caller's own
|
|
27
|
+
* SID and grants `(F)` — which separates "Modify-only directory" from "the
|
|
28
|
+
* documented prerequisite is wrong", the open question upstream. For the PTY
|
|
29
|
+
* family it is the **effective sandbox mode**, which is why that advisory is
|
|
30
|
+
* only ever built with the mode the call actually ran under.
|
|
19
31
|
*
|
|
20
32
|
* @module
|
|
21
33
|
*/
|
|
22
|
-
import type { ProvisioningFailure } from './signature.js';
|
|
23
|
-
|
|
24
|
-
|
|
34
|
+
import type { ProvisioningFailure, RecognizedFailure } from './signature.js';
|
|
35
|
+
import type { SandboxModeName } from './mode.js';
|
|
36
|
+
/** The upstream threads the ACL advisory is a stopgap for. */
|
|
37
|
+
export declare const ACL_DISCUSSIONS = "#7538 / #7622 / #7646 / #7720";
|
|
38
|
+
/** The upstream thread the persistent-shell advisory is a stopgap for. */
|
|
39
|
+
export declare const PTY_DISCUSSIONS = "#7638";
|
|
25
40
|
/** The documented prerequisite, quoted from the backend's README. */
|
|
26
41
|
export declare const PREREQUISITE = "granted directories must be caller-owned and grant `WRITE_OWNER`";
|
|
42
|
+
/**
|
|
43
|
+
* Where a user's own preset changes actually live.
|
|
44
|
+
*
|
|
45
|
+
* This is deliberately **not** the legacy `$DSH_HOME/.agent-presets/<id>/`
|
|
46
|
+
* directory: that shape predates declarative presets and **nothing reads it any
|
|
47
|
+
* more** (the registry "neither scans directories nor accepts preset paths").
|
|
48
|
+
* A preset is a `@deepseek-ai/dsh-agent-preset` row, and changing one means
|
|
49
|
+
* overriding or inserting that row in a patch layer, which is what this path
|
|
50
|
+
* names. Advising a folder the harness stopped reading would be the same defect
|
|
51
|
+
* this plugin exists to answer — a remedy that does not work, delivered
|
|
52
|
+
* confidently.
|
|
53
|
+
*/
|
|
54
|
+
export declare const PROFILE_PATCH = "$DSH_HOME/profiles/<profile>/cordis.patch.yml";
|
|
55
|
+
/** The machine-wide patch layer, for a change that should hold in every profile. */
|
|
56
|
+
export declare const GLOBAL_PATCH = "$DSH_HOME/cordis.patch.yml";
|
|
57
|
+
/** The row id the shipped `minimal` preset is declared under. */
|
|
58
|
+
export declare const MINIMAL_PRESET_ROW = "preset-minimal";
|
|
59
|
+
/** The one-shot shell tool the `standard` preset mounts on Windows. */
|
|
60
|
+
export declare const ONE_SHOT_SHELL = "@deepseek-ai/dsh-tool-pwsh";
|
|
61
|
+
/**
|
|
62
|
+
* The two remedies that look like the fix and are not.
|
|
63
|
+
*
|
|
64
|
+
* Both were applied by the reporter of `#7720` before finding the one that
|
|
65
|
+
* works, and both are the *natural* reach: making yourself the owner and
|
|
66
|
+
* resetting the directory's ACL are how one normally repairs a Windows
|
|
67
|
+
* permission problem. They fail here for two different reasons, and naming the
|
|
68
|
+
* reason is what makes this section worth its lines — a reader who already
|
|
69
|
+
* tried them learns why, and a reader who has not is spared the attempt. See
|
|
70
|
+
* {@link nonFixes} for when this is emitted.
|
|
71
|
+
*/
|
|
72
|
+
export declare const NOT_FIXES: readonly ["takeown /F \"<dir>\" /R /D Y", "icacls \"<dir>\" /reset /T /C"];
|
|
73
|
+
/** What the caller knows about the failing call, beyond the failure text. */
|
|
74
|
+
export interface AdvisoryContext {
|
|
75
|
+
/** URL quoted in place of the discussions list; optional. */
|
|
76
|
+
readonly href?: string;
|
|
77
|
+
/** The tool whose call failed, quoted back so the advice is about that call. */
|
|
78
|
+
readonly tool?: string;
|
|
79
|
+
/**
|
|
80
|
+
* The sandbox mode the failing call ran under. Required by the
|
|
81
|
+
* `pty-startup` family — the whole diagnosis is the mode — and unused by the
|
|
82
|
+
* ACL family.
|
|
83
|
+
*/
|
|
84
|
+
readonly mode?: SandboxModeName;
|
|
85
|
+
}
|
|
27
86
|
/**
|
|
28
87
|
* Build the advisory attached to the failing tool result.
|
|
88
|
+
*
|
|
89
|
+
* The family decides everything: one function so a caller does not have to
|
|
90
|
+
* remember which family needs which fact, and so the mode requirement of the
|
|
91
|
+
* PTY family is enforced by construction rather than by convention.
|
|
29
92
|
* @param failure - the recognized failure.
|
|
30
|
-
* @param
|
|
31
|
-
* @returns the user-role notice text, with
|
|
93
|
+
* @param context - what the caller knows about the failing call.
|
|
94
|
+
* @returns the user-role notice text, with any remedy ready to paste.
|
|
95
|
+
* @throws when a PTY failure is advised without its resolved sandbox mode.
|
|
32
96
|
*/
|
|
33
|
-
export declare function advisoryText(failure:
|
|
97
|
+
export declare function advisoryText(failure: RecognizedFailure, context?: AdvisoryContext): string;
|
|
34
98
|
/**
|
|
35
99
|
* Build the pre-dispatch denial for the optional fail-fast half.
|
|
100
|
+
*
|
|
101
|
+
* The blocking half is deliberately **ACL-only**, and this function's parameter
|
|
102
|
+
* type is where that is enforced. The PTY family gets an advisory and nothing
|
|
103
|
+
* else, for a reason that is about the remedy rather than about the failure:
|
|
104
|
+
* the ACL remedy is a command the user can run *while the session continues*,
|
|
105
|
+
* so refusing further identical calls cannot make the session unfinishable —
|
|
106
|
+
* spending the budget always lets the call through, and a repaired environment
|
|
107
|
+
* is discovered by exactly that. The PTY remedy is a preset swap, which happens
|
|
108
|
+
* between sessions; refusing calls could only pad a session that is already
|
|
109
|
+
* unable to do the thing being refused.
|
|
36
110
|
* @param failure - the recognized failure.
|
|
37
111
|
* @param observed - how many provisioning failures this agent has produced.
|
|
38
112
|
* @param denial - this denial's 1-based ordinal.
|