@fishwithcat/dsh-user-approval 0.0.0-stage → 0.2.2-alpha.2

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/lib/index.js ADDED
@@ -0,0 +1,272 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Service } from "@deepseek-ai/cordis";
3
+ import z from "@deepseek-ai/schemastery";
4
+ import { z as z$1 } from "zod";
5
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
6
+ import { scopeTarget } from "@deepseek-ai/dsh-scope";
7
+ import { SessionSeq } from "@deepseek-ai/dsh-session";
8
+ //#region lib/types/types.js
9
+ /**
10
+ * Wire-safe approval identifiers and outcome vocabulary, free of
11
+ * cordis/service imports so browser type chains can
12
+ * consume them without loading this package's Context augmentation.
13
+ * @module @deepseek-ai/dsh-user-approval/types
14
+ */
15
+ /**
16
+ * Brand a string as an {@link ApprovalRequestId}.
17
+ * @param id - the raw id string to brand.
18
+ * @returns the same string carrying the brand.
19
+ */
20
+ function ApprovalRequestId(id) {
21
+ return id;
22
+ }
23
+ //#endregion
24
+ //#region lib/types/index.js
25
+ /**
26
+ * Service Definition for the approval capability seam, covering requests,
27
+ * cancellation, audit, per-session policy, and remembered per-tool grants.
28
+ * Missing answerers fail closed.
29
+ * @module @deepseek-ai/dsh-user-approval
30
+ */
31
+ /** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
32
+ const OUTCOMES = [
33
+ "allowed-once",
34
+ "rejected",
35
+ "cancelled",
36
+ "unavailable"
37
+ ];
38
+ /** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
39
+ const APPROVAL_POLICIES = ["ask", "never"];
40
+ /** Model-facing statement for the deterministic `'never'` policy. */
41
+ const NEVER_SENTENCE = "Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).";
42
+ /** Model-facing statement for an interactive policy that may still fail closed. */
43
+ const ASK_SENTENCE = "Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.";
44
+ /** Model-facing policy names in switch notices; a bare `never` reads as "approval is never granted". */
45
+ const POLICY_NOTICE_NAMES = {
46
+ ask: "ask the user for approval",
47
+ never: "never ask the user for approval"
48
+ };
49
+ const approvalStateDefinition = {
50
+ key: "approvalState",
51
+ stateVersion: 1,
52
+ stateSchema: z$1.object({
53
+ policy: z$1.enum(["ask", "never"]).nullable(),
54
+ turnOpen: z$1.boolean()
55
+ }),
56
+ init: () => ({
57
+ policy: null,
58
+ turnOpen: false
59
+ }),
60
+ apply(state, event) {
61
+ switch (event.type) {
62
+ case "approval/policy": return state.policy === event.data.policy ? state : {
63
+ ...state,
64
+ policy: event.data.policy
65
+ };
66
+ case "turn/start": return state.turnOpen ? state : {
67
+ ...state,
68
+ turnOpen: true
69
+ };
70
+ case "turn/end": return state.turnOpen ? {
71
+ ...state,
72
+ turnOpen: false
73
+ } : state;
74
+ default: return state;
75
+ }
76
+ }
77
+ };
78
+ /**
79
+ * Append the sole durable representation of a session policy override. Invalid
80
+ * values throw before the log changes; projected state follows the committed record.
81
+ * @param session - the session the override belongs to.
82
+ * @param policy - the policy in effect until the next switch.
83
+ */
84
+ function setApprovalPolicy(session, policy) {
85
+ if (!APPROVAL_POLICIES.includes(policy)) throw new TypeError("approval policy must be one of \"ask\" or \"never\"");
86
+ session.append("approval/policy", { policy });
87
+ }
88
+ /**
89
+ * Approval service that applies session policy and remembered per-tool grants
90
+ * before answerers and logs every ask/outcome pair to the requesting session.
91
+ * It exposes deterministic policy changes to the model through the
92
+ * runtime-context snapshot and switch notices.
93
+ */
94
+ var ApprovalService = class extends Service {
95
+ config;
96
+ static inject = ["sessionProjections"];
97
+ static Config = z.object({ policy: z.union(["ask", "never"]).default("ask") });
98
+ approvalProjectionHandle;
99
+ constructor(ctx, config) {
100
+ super(ctx, "approval");
101
+ this.config = config;
102
+ }
103
+ async [Service.init]() {
104
+ this.approvalProjectionHandle = await this.ctx.sessionProjections.register(approvalStateDefinition);
105
+ if (this.ctx.fiber.uid === null) return;
106
+ const effective = (agent) => this.effectivePolicy(agent.session);
107
+ await this.ctx.inject(["systemPrompt"], (scope) => {
108
+ scope.systemPrompt.context({
109
+ name: "approval:policy",
110
+ order: scope.systemPrompt.getContextOrder("APPROVAL_POLICY"),
111
+ text: (context) => {
112
+ const agent = context.agent;
113
+ if (agent === void 0) return "";
114
+ return effective(agent) === "never" ? NEVER_SENTENCE : ASK_SENTENCE;
115
+ }
116
+ });
117
+ });
118
+ }
119
+ /**
120
+ * Switch one live agent's policy and queue the transition for its next model
121
+ * step. Session initialization uses {@link setApprovalPolicy} directly
122
+ * because there is no previously visible policy to change.
123
+ * @param agent - the live agent whose policy is changing.
124
+ * @param policy - the new effective policy.
125
+ */
126
+ setPolicy(agent, policy) {
127
+ const previous = this.effectivePolicy(agent.session);
128
+ if (previous === policy) return;
129
+ setApprovalPolicy(agent.session, policy);
130
+ agent.inject(createUserMessage({
131
+ content: [{
132
+ type: "text",
133
+ text: `The user changed the approval policy from "${POLICY_NOTICE_NAMES[previous]}" to "${POLICY_NOTICE_NAMES[policy]}".`
134
+ }],
135
+ source: { kind: "user-approval" }
136
+ }));
137
+ }
138
+ /**
139
+ * Ask the composed answerers to decide one readonly same-process request.
140
+ * The service borrows the request, agent, session, and live signal directly.
141
+ * A remembered grant for the tool resolves the ask `allowed-once` before any
142
+ * answerer sees it. The request requires an open turn because the audit pair
143
+ * must be enclosed by the durable log's commit/replay boundary; an idle ask
144
+ * rejects before appending anything. The answerer phase always produces an
145
+ * outcome: an aborted signal yields `'cancelled'`, a missing or throwing answerer yields
146
+ * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
147
+ * normalized to `'unavailable'`. A failure that prevents either audit append
148
+ * from committing still rejects because returning an unlogged decision would
149
+ * violate the pair. Session contains post-commit observer failures, so an
150
+ * authoritative append cannot reject the request or suppress its matching
151
+ * audit event.
152
+ * @param req - the pending decision (agent, tool identity, reason, signal).
153
+ * @returns the closed outcome; `'allowed-once'` is the only grant.
154
+ * @throws when no turn is open or either audit event fails before the session
155
+ * append commit point.
156
+ */
157
+ async request(req) {
158
+ const session = req.agent.session;
159
+ if (!this.approvalStateOf(session).turnOpen) throw new Error("approval.request() outside an open turn: the approval/asked + approval/decided audit pair must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). Ask from inside the turn that needs the decision.");
160
+ const id = ApprovalRequestId(randomUUID());
161
+ session.append("approval/asked", {
162
+ id,
163
+ toolName: req.toolName,
164
+ ...req.callId !== void 0 ? { callId: req.callId } : {},
165
+ ...req.reason !== void 0 ? { reason: req.reason } : {}
166
+ });
167
+ const outcome = await this.decide(req, session);
168
+ const remembered = outcome === "allowed-once" && this.isGranted(req.agent, req.toolName);
169
+ session.append("approval/decided", {
170
+ id,
171
+ outcome,
172
+ ...remembered ? { remembered: true } : {}
173
+ });
174
+ return outcome;
175
+ }
176
+ /**
177
+ * Remember one tool's approval for a session and its live descendants. Every
178
+ * later ask for that tool resolves `allowed-once` without consulting an
179
+ * answerer while the effective policy is `ask`; `never` still rejects first.
180
+ * Recording nothing when the grant already exists keeps replay and repeated
181
+ * Always allow choices from growing the log.
182
+ * @param session - the session whose subtree the grant covers.
183
+ * @param toolName - the tool whose future asks the grant covers.
184
+ * @throws when the tool name is empty.
185
+ */
186
+ grant(session, toolName) {
187
+ if (toolName.trim() === "") throw new TypeError("approval grant requires a non-empty tool name");
188
+ if (this.grantedIn(session, toolName)) return;
189
+ session.append("approval/grant", { toolName });
190
+ }
191
+ /**
192
+ * Whether a remembered grant covers this Agent's ask for the tool. The walk
193
+ * reads the Agent's session, then each live ancestor through `parentSession`;
194
+ * a vanished ancestor ends it, so an orphaned child never inherits a grant it
195
+ * cannot prove.
196
+ * @param agent - the asking Agent.
197
+ * @param toolName - the tool being asked about.
198
+ * @returns true when the Agent or a live ancestor holds the grant.
199
+ */
200
+ isGranted(agent, toolName) {
201
+ let current = agent;
202
+ for (;;) {
203
+ if (this.grantedIn(current.session, toolName)) return true;
204
+ const parentId = current.session.header.parentSession;
205
+ if (parentId === void 0) return false;
206
+ const parent = this.ctx.get("agents")?.get(parentId);
207
+ if (parent === void 0) return false;
208
+ current = parent;
209
+ }
210
+ }
211
+ /**
212
+ * Read one session's own grants without walking its lineage.
213
+ * @param session - session whose log supplies the grants.
214
+ * @param toolName - tool whose grant is sought.
215
+ * @returns whether this session recorded a grant for the tool.
216
+ */
217
+ grantedIn(session, toolName) {
218
+ for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
219
+ const event = session.eventAt(SessionSeq(seq));
220
+ if (event?.type === "approval/grant" && event.data.toolName === toolName) return true;
221
+ }
222
+ return false;
223
+ }
224
+ /**
225
+ * The session's effective policy: its projected override, else the
226
+ * configured default (the schema already defaulted an omitted policy to
227
+ * `'ask'`; the `??` only narrows the optional-input TYPE).
228
+ * @param session - the exact accepted session whose policy applies.
229
+ * @returns the policy every ask for this session resolves under right now.
230
+ */
231
+ effectivePolicy(session) {
232
+ return this.overrideOf(session) ?? this.config.policy ?? "ask";
233
+ }
234
+ /**
235
+ * Read the session override without applying the configured default.
236
+ * @param session - session whose log supplies the override.
237
+ * @returns the last logged policy, or `undefined` without one.
238
+ */
239
+ overrideOf(session) {
240
+ return this.approvalStateOf(session).policy ?? void 0;
241
+ }
242
+ approvalStateOf(session) {
243
+ return this.approvalProjectionHandle.stateOf(session);
244
+ }
245
+ /**
246
+ * Dispatch the waterfall, contained and raced against the request signal.
247
+ * @param req - the borrowed public request.
248
+ * @param session - the request agent's session used for policy lookup.
249
+ * @returns the normalized closed outcome.
250
+ */
251
+ async decide(req, session) {
252
+ const signal = req.signal;
253
+ if (signal?.aborted) return "cancelled";
254
+ if (this.effectivePolicy(session) === "never") return "rejected";
255
+ if (this.isGranted(req.agent, req.toolName)) return "allowed-once";
256
+ const answer = Promise.resolve().then(() => this.ctx.waterfall(scopeTarget(req.agent, req.agent), "approval/request", req, () => Promise.resolve("unavailable"))).then((outcome) => OUTCOMES.includes(outcome) ? outcome : "unavailable", () => "unavailable");
257
+ if (signal === void 0) return answer;
258
+ return await new Promise((resolve) => {
259
+ const onAbort = () => {
260
+ signal.removeEventListener("abort", onAbort);
261
+ resolve("cancelled");
262
+ };
263
+ signal.addEventListener("abort", onAbort, { once: true });
264
+ answer.then((outcome) => {
265
+ signal.removeEventListener("abort", onAbort);
266
+ resolve(outcome);
267
+ });
268
+ });
269
+ }
270
+ };
271
+ //#endregion
272
+ export { APPROVAL_POLICIES, ApprovalRequestId, ApprovalService, ApprovalService as default, setApprovalPolicy };
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Service Definition for the approval capability seam, covering requests,
3
+ * cancellation, audit, per-session policy, and remembered per-tool grants.
4
+ * Missing answerers fail closed.
5
+ * @module @deepseek-ai/dsh-user-approval
6
+ */
7
+ import { Context, Service } from '@deepseek-ai/cordis';
8
+ import z from '@deepseek-ai/schemastery';
9
+ import type { Agent } from '@deepseek-ai/dsh-agent';
10
+ import { type ToolCallId } from '@deepseek-ai/dsh-llm';
11
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm';
12
+ declare module '@deepseek-ai/dsh-llm' {
13
+ interface MessageSourceMap {
14
+ 'user-approval': {
15
+ kind: 'user-approval';
16
+ } & ContextFormed;
17
+ }
18
+ }
19
+ import { type Session } from '@deepseek-ai/dsh-session';
20
+ declare module '@deepseek-ai/cordis' {
21
+ interface Context {
22
+ approval: ApprovalService;
23
+ }
24
+ }
25
+ declare module '@deepseek-ai/dsh-session/types' {
26
+ interface SessionEventMap {
27
+ /**
28
+ * The session's approval policy was switched — log-only, durable,
29
+ * replayable, never in the model transcript (the model learns the policy
30
+ * from the runtime-context snapshot and live switch notices). The LAST
31
+ * such event is the session's override.
32
+ * `source: 'delegation'` marks an override seeded into a child; an absent
33
+ * source is a runtime switch.
34
+ */
35
+ 'approval/policy': {
36
+ policy: ApprovalPolicy;
37
+ /** Marks an override seeded into a child at delegation. */
38
+ source?: 'delegation';
39
+ };
40
+ }
41
+ }
42
+ import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts';
43
+ export { ApprovalRequestId } from './types.ts';
44
+ export type { ApprovalOutcome } from './types.ts';
45
+ /**
46
+ * A session's approval policy — what happens to an {@link ApprovalService}
47
+ * ask BEFORE any interactive answerer sees it:
48
+ *
49
+ * - `'ask'` (the default) — delegate to the composed answerers; with none
50
+ * composed the chain falls through to the fail-closed `'unavailable'`.
51
+ * - `'never'` — never prompt anyone: every ask resolves `'rejected'`
52
+ * deterministically. The strict headless stance (CI, unattended runs) and
53
+ * the policy whose outcome is knowable without asking.
54
+ */
55
+ export type ApprovalPolicy = 'ask' | 'never';
56
+ /** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
57
+ export declare const APPROVAL_POLICIES: readonly ApprovalPolicy[];
58
+ /**
59
+ * Append the sole durable representation of a session policy override. Invalid
60
+ * values throw before the log changes; projected state follows the committed record.
61
+ * @param session - the session the override belongs to.
62
+ * @param policy - the policy in effect until the next switch.
63
+ */
64
+ export declare function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void;
65
+ /**
66
+ * Readonly same-process permission question. `callId` links to an already
67
+ * presented tool call, so arguments are not duplicated here.
68
+ */
69
+ export interface ApprovalRequest extends ApprovalRequestEvent {
70
+ /**
71
+ * The agent on whose behalf the question is asked. Routes the question (a
72
+ * UI answerer only answers for agents it owns) and receives the audit
73
+ * events on its session log.
74
+ */
75
+ readonly agent: Agent;
76
+ /** The tool the question is about (presentation and audit). */
77
+ readonly toolName: string;
78
+ /**
79
+ * The exact tool call being decided, when the asker has one — lets a UI
80
+ * attach the prompt to the tool call it already streamed.
81
+ */
82
+ readonly callId?: ToolCallId;
83
+ /** The asker's human-readable explanation of WHY it is asking. */
84
+ readonly reason?: string;
85
+ /**
86
+ * Aborting withdraws the question: the request settles `'cancelled'`
87
+ * immediately and a late answer from a still-pending answerer is discarded.
88
+ */
89
+ readonly signal?: AbortSignal;
90
+ }
91
+ /** Plugin config. All optional — `static Config` supplies the defaults. */
92
+ export interface Config {
93
+ /**
94
+ * The deployment's default {@link ApprovalPolicy} for sessions without an
95
+ * `approval/policy` override — `'ask'` delegates to the composed answerers
96
+ * (fail-closed with none); `'never'` auto-rejects every ask without
97
+ * prompting (the deterministic CI/unattended stance).
98
+ */
99
+ readonly policy?: ApprovalPolicy;
100
+ }
101
+ /**
102
+ * Approval service that applies session policy and remembered per-tool grants
103
+ * before answerers and logs every ask/outcome pair to the requesting session.
104
+ * It exposes deterministic policy changes to the model through the
105
+ * runtime-context snapshot and switch notices.
106
+ */
107
+ export declare class ApprovalService extends Service {
108
+ config: Config;
109
+ static inject: string[];
110
+ static Config: z<Config>;
111
+ private approvalProjectionHandle;
112
+ constructor(ctx: Context, config: Config);
113
+ protected [Service.init](): Promise<void>;
114
+ /**
115
+ * Switch one live agent's policy and queue the transition for its next model
116
+ * step. Session initialization uses {@link setApprovalPolicy} directly
117
+ * because there is no previously visible policy to change.
118
+ * @param agent - the live agent whose policy is changing.
119
+ * @param policy - the new effective policy.
120
+ */
121
+ setPolicy(agent: Agent, policy: ApprovalPolicy): void;
122
+ /**
123
+ * Ask the composed answerers to decide one readonly same-process request.
124
+ * The service borrows the request, agent, session, and live signal directly.
125
+ * A remembered grant for the tool resolves the ask `allowed-once` before any
126
+ * answerer sees it. The request requires an open turn because the audit pair
127
+ * must be enclosed by the durable log's commit/replay boundary; an idle ask
128
+ * rejects before appending anything. The answerer phase always produces an
129
+ * outcome: an aborted signal yields `'cancelled'`, a missing or throwing answerer yields
130
+ * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
131
+ * normalized to `'unavailable'`. A failure that prevents either audit append
132
+ * from committing still rejects because returning an unlogged decision would
133
+ * violate the pair. Session contains post-commit observer failures, so an
134
+ * authoritative append cannot reject the request or suppress its matching
135
+ * audit event.
136
+ * @param req - the pending decision (agent, tool identity, reason, signal).
137
+ * @returns the closed outcome; `'allowed-once'` is the only grant.
138
+ * @throws when no turn is open or either audit event fails before the session
139
+ * append commit point.
140
+ */
141
+ request(req: ApprovalRequest): Promise<ApprovalOutcome>;
142
+ /**
143
+ * Remember one tool's approval for a session and its live descendants. Every
144
+ * later ask for that tool resolves `allowed-once` without consulting an
145
+ * answerer while the effective policy is `ask`; `never` still rejects first.
146
+ * Recording nothing when the grant already exists keeps replay and repeated
147
+ * Always allow choices from growing the log.
148
+ * @param session - the session whose subtree the grant covers.
149
+ * @param toolName - the tool whose future asks the grant covers.
150
+ * @throws when the tool name is empty.
151
+ */
152
+ grant(session: Session, toolName: string): void;
153
+ /**
154
+ * Whether a remembered grant covers this Agent's ask for the tool. The walk
155
+ * reads the Agent's session, then each live ancestor through `parentSession`;
156
+ * a vanished ancestor ends it, so an orphaned child never inherits a grant it
157
+ * cannot prove.
158
+ * @param agent - the asking Agent.
159
+ * @param toolName - the tool being asked about.
160
+ * @returns true when the Agent or a live ancestor holds the grant.
161
+ */
162
+ isGranted(agent: Agent, toolName: string): boolean;
163
+ /**
164
+ * Read one session's own grants without walking its lineage.
165
+ * @param session - session whose log supplies the grants.
166
+ * @param toolName - tool whose grant is sought.
167
+ * @returns whether this session recorded a grant for the tool.
168
+ */
169
+ private grantedIn;
170
+ /**
171
+ * The session's effective policy: its projected override, else the
172
+ * configured default (the schema already defaulted an omitted policy to
173
+ * `'ask'`; the `??` only narrows the optional-input TYPE).
174
+ * @param session - the exact accepted session whose policy applies.
175
+ * @returns the policy every ask for this session resolves under right now.
176
+ */
177
+ effectivePolicy(session: Session): ApprovalPolicy;
178
+ /**
179
+ * Read the session override without applying the configured default.
180
+ * @param session - session whose log supplies the override.
181
+ * @returns the last logged policy, or `undefined` without one.
182
+ */
183
+ overrideOf(session: Session): ApprovalPolicy | undefined;
184
+ private approvalStateOf;
185
+ /**
186
+ * Dispatch the waterfall, contained and raced against the request signal.
187
+ * @param req - the borrowed public request.
188
+ * @param session - the request agent's session used for policy lookup.
189
+ * @returns the normalized closed outcome.
190
+ */
191
+ private decide;
192
+ }
193
+ export default ApprovalService;
194
+ //# sourceMappingURL=index.d.ts.map