dsh-codex-approval 0.2.2 → 0.4.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/rules.js CHANGED
@@ -1,90 +1,90 @@
1
- /**
2
- * dsh-codex-approval — rules.js
3
- *
4
- * Codex-style rule matching. A rule matches a request via a single glob
5
- * pattern over the "matchable text": `ToolName(args preview) reason:<reason>`.
6
- * Examples:
7
- * - `Bash(git *)` — the recovered bash command starts with "git "
8
- * - `Bash(rm -rf /*)` — destructive command
9
- * - `reason:*curl*` — the approval reason mentions curl
10
- *
11
- * Evaluation priority is safety-first regardless of list order:
12
- * deny > ask > allow
13
- * (an explicit ask or deny can never be overridden by a blanket allow,
14
- * mirroring Codex where ask/reject rules take precedence over auto-approve).
15
- */
16
-
17
- /** Classic glob match: `*` = any sequence (incl. empty), `?` = one char. Case-insensitive. */
18
- export function wildcardMatch(pattern, text) {
19
- if (typeof pattern !== "string" || typeof text !== "string") return false;
20
- pattern = pattern.toLowerCase();
21
- text = text.toLowerCase();
22
- let pi = 0;
23
- let ti = 0;
24
- let star = -1;
25
- let mark = 0;
26
- while (ti < text.length) {
27
- if (pi < pattern.length && (pattern[pi] === "?" || pattern[pi] === text[ti])) {
28
- pi += 1;
29
- ti += 1;
30
- } else if (pi < pattern.length && pattern[pi] === "*") {
31
- star = pi;
32
- pi += 1;
33
- mark = ti;
34
- } else if (star !== -1) {
35
- pi = star + 1;
36
- ti = mark + 1;
37
- mark += 1;
38
- } else {
39
- return false;
40
- }
41
- }
42
- while (pi < pattern.length && pattern[pi] === "*") pi += 1;
43
- return pi === pattern.length;
44
- }
45
-
46
- /**
47
- * Build the single string rules match against.
48
- * @param req - { toolName, argsText, reason }
49
- */
50
- export function matchableText(req) {
51
- const bits = [];
52
- if (req.toolName) bits.push(`${req.toolName}(${req.argsText ?? ""})`);
53
- if (req.reason) bits.push(`reason:${req.reason}`);
54
- return bits.join(" ");
55
- }
56
-
57
- /**
58
- * The surfaces a rule pattern is tested against, in order: the tool call
59
- * alone (`ToolName(args)`), the reason alone (`reason:...`), then the
60
- * combined string. This lets `Bash(git *)` match regardless of an appended
61
- * reason, and `reason:*curl*` match the reason alone.
62
- */
63
- export function matchSurfaces(req) {
64
- const surfaces = [];
65
- if (req.toolName) surfaces.push(`${req.toolName}(${req.argsText ?? ""})`);
66
- if (req.reason) surfaces.push(`reason:${req.reason}`);
67
- const combined = surfaces.join(" ");
68
- if (!surfaces.includes(combined)) surfaces.push(combined);
69
- return surfaces.filter((surface) => surface !== "");
70
- }
71
-
72
- /**
73
- * Evaluate an ordered rule list against one request.
74
- * @param rules - [{ match: string, action: "allow"|"ask"|"deny" }]
75
- * @param req - { toolName, argsText, reason }
76
- * @returns the first matching rule under deny > ask > allow priority, or null.
77
- */
78
- export function evaluateRules(rules, req) {
79
- const surfaces = matchSurfaces(req);
80
- if (surfaces.length === 0) return null;
81
- for (const action of ["deny", "ask", "allow"]) {
82
- for (const rule of rules) {
83
- if (rule.action !== action) continue;
84
- for (const surface of surfaces) {
85
- if (wildcardMatch(rule.match, surface)) return rule;
86
- }
87
- }
88
- }
89
- return null;
90
- }
1
+ /**
2
+ * dsh-codex-approval — rules.js
3
+ *
4
+ * Codex-style rule matching. A rule matches a request via a single glob
5
+ * pattern over the "matchable text": `ToolName(args preview) reason:<reason>`.
6
+ * Examples:
7
+ * - `Bash(git *)` — the recovered bash command starts with "git "
8
+ * - `Bash(rm -rf /*)` — destructive command
9
+ * - `reason:*curl*` — the approval reason mentions curl
10
+ *
11
+ * Evaluation priority is safety-first regardless of list order:
12
+ * deny > ask > allow
13
+ * (an explicit ask or deny can never be overridden by a blanket allow,
14
+ * mirroring Codex where ask/reject rules take precedence over auto-approve).
15
+ */
16
+
17
+ /** Classic glob match: `*` = any sequence (incl. empty), `?` = one char. Case-insensitive. */
18
+ export function wildcardMatch(pattern, text) {
19
+ if (typeof pattern !== "string" || typeof text !== "string") return false;
20
+ pattern = pattern.toLowerCase();
21
+ text = text.toLowerCase();
22
+ let pi = 0;
23
+ let ti = 0;
24
+ let star = -1;
25
+ let mark = 0;
26
+ while (ti < text.length) {
27
+ if (pi < pattern.length && (pattern[pi] === "?" || pattern[pi] === text[ti])) {
28
+ pi += 1;
29
+ ti += 1;
30
+ } else if (pi < pattern.length && pattern[pi] === "*") {
31
+ star = pi;
32
+ pi += 1;
33
+ mark = ti;
34
+ } else if (star !== -1) {
35
+ pi = star + 1;
36
+ ti = mark + 1;
37
+ mark += 1;
38
+ } else {
39
+ return false;
40
+ }
41
+ }
42
+ while (pi < pattern.length && pattern[pi] === "*") pi += 1;
43
+ return pi === pattern.length;
44
+ }
45
+
46
+ /**
47
+ * Build the single string rules match against.
48
+ * @param req - { toolName, argsText, reason }
49
+ */
50
+ export function matchableText(req) {
51
+ const bits = [];
52
+ if (req.toolName) bits.push(`${req.toolName}(${req.argsText ?? ""})`);
53
+ if (req.reason) bits.push(`reason:${req.reason}`);
54
+ return bits.join(" ");
55
+ }
56
+
57
+ /**
58
+ * The surfaces a rule pattern is tested against, in order: the tool call
59
+ * alone (`ToolName(args)`), the reason alone (`reason:...`), then the
60
+ * combined string. This lets `Bash(git *)` match regardless of an appended
61
+ * reason, and `reason:*curl*` match the reason alone.
62
+ */
63
+ export function matchSurfaces(req) {
64
+ const surfaces = [];
65
+ if (req.toolName) surfaces.push(`${req.toolName}(${req.argsText ?? ""})`);
66
+ if (req.reason) surfaces.push(`reason:${req.reason}`);
67
+ const combined = surfaces.join(" ");
68
+ if (!surfaces.includes(combined)) surfaces.push(combined);
69
+ return surfaces.filter((surface) => surface !== "");
70
+ }
71
+
72
+ /**
73
+ * Evaluate an ordered rule list against one request.
74
+ * @param rules - [{ match: string, action: "allow"|"ask"|"deny" }]
75
+ * @param req - { toolName, argsText, reason }
76
+ * @returns the first matching rule under deny > ask > allow priority, or null.
77
+ */
78
+ export function evaluateRules(rules, req) {
79
+ const surfaces = matchSurfaces(req);
80
+ if (surfaces.length === 0) return null;
81
+ for (const action of ["deny", "ask", "allow"]) {
82
+ for (const rule of rules) {
83
+ if (rule.action !== action) continue;
84
+ for (const surface of surfaces) {
85
+ if (wildcardMatch(rule.match, surface)) return rule;
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ }
package/transcript.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * dsh-codex-approval — transcript.js
3
+ *
4
+ * Compact-session-transcript builder (`transcript: "short"`). Turns the live
5
+ * session event stream into a bounded, skeletonized context block for the AI
6
+ * approval judge, so the judge can see user intent and the surrounding tool
7
+ * chain — not just the bare command.
8
+ *
9
+ * Pipeline:
10
+ * 1. semantic filter — keep only user/message, tool-calls, tool/results,
11
+ * turn markers; drop streaming chunks and plugin-
12
+ * sourced user messages (denyFeedback / time-context
13
+ * injections must never be fed back to the judge).
14
+ * 2. two-level window — short window (recent user message + ≤3 tool
15
+ * calls) rendered in full skeleton; long window
16
+ * (older user messages only) as an intent line.
17
+ * 3. head/tail truncation — overlong messages keep head + tail with an
18
+ * elision counter (error-report pastes: head =
19
+ * action, tail = crux, middle = noise).
20
+ * 4. budget tiers — total bounded by transcriptMaxChars; overflow
21
+ * drops lowest-priority items first (denial history
22
+ * → cwd → oldest long-window entries).
23
+ *
24
+ * Pure functions only; everything defensive, never throws into the
25
+ * approval path.
26
+ */
27
+
28
+ /** Head/tail caps per item class, in chars. */
29
+ const CAPS = {
30
+ shortUser: { head: 600, tail: 600 }, // the most recent user message (P0)
31
+ longUser: { head: 120, tail: 80 }, // older intent-line entries (P2)
32
+ tool: { head: 200, tail: 0 } // tool-call arguments (P1)
33
+ };
34
+
35
+ /** Elide the middle of an over-long text: `head…〔省略 N 字符〕…tail`. */
36
+ export function truncateMiddle(text, headChars, tailChars) {
37
+ if (typeof text !== "string" || text.length <= headChars + tailChars) return text;
38
+ const head = text.slice(0, headChars);
39
+ const tail = tailChars > 0 ? text.slice(text.length - tailChars) : "";
40
+ const omitted = text.length - headChars - tailChars;
41
+ return omitted > 0
42
+ ? `${head}…〔省略 ${omitted} 字符〕…${tail}`
43
+ : text;
44
+ }
45
+
46
+ /**
47
+ * Extract the text of a user/message event. Returns "" for non-text shapes.
48
+ */
49
+ function userText(data) {
50
+ const content = data?.content;
51
+ if (Array.isArray(content)) {
52
+ return content
53
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
54
+ .map((part) => part.text)
55
+ .join(" ");
56
+ }
57
+ if (content && content.type === "text" && typeof content.text === "string") return content.text;
58
+ return "";
59
+ }
60
+
61
+ /**
62
+ * Collect semantic items from the raw event stream, newest first.
63
+ * Plugin-sourced user messages and streaming chunks are excluded here.
64
+ * @param events - session.events
65
+ * @returns array of { seq, kind, ... } with seq counting only semantic items
66
+ * (newest first, so index 0 is the most recent).
67
+ */
68
+ export function collectSemanticItems(events) {
69
+ if (!Array.isArray(events)) return [];
70
+ const items = [];
71
+ for (let i = events.length - 1; i >= 0; i -= 1) {
72
+ const event = events[i];
73
+ if (event === null || typeof event !== "object") continue;
74
+ const type = event.type;
75
+ if (type === "user/message") {
76
+ const source = event.data?.source;
77
+ if (source?.kind === "plugin") continue; // never feed injections back
78
+ const text = userText(event.data);
79
+ if (text === "") continue;
80
+ items.push({ seq: items.length, kind: "user", text, time: event.time });
81
+ } else if (type === "assistant/message") {
82
+ const content = event.data?.message?.content;
83
+ if (!Array.isArray(content)) continue;
84
+ for (const part of content) {
85
+ if (part?.type !== "tool-call") continue;
86
+ const args = typeof part.arguments === "string" ? part.arguments : "";
87
+ items.push({ seq: items.length, kind: "tool", name: part.name, args, time: event.time });
88
+ }
89
+ } else if (type === "tool/result") {
90
+ const msg = event.data?.message;
91
+ const error = event.data?.error;
92
+ const text = typeof msg?.text === "string" ? msg.text
93
+ : Array.isArray(msg?.content)
94
+ ? msg.content.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text).join(" ")
95
+ : "";
96
+ items.push({
97
+ seq: items.length,
98
+ kind: "result",
99
+ ok: error === undefined,
100
+ errorCode: error?.code,
101
+ text: text === "" ? "" : truncateMiddle(text, 120, 60),
102
+ time: event.time
103
+ });
104
+ }
105
+ }
106
+ return items;
107
+ }
108
+
109
+ /**
110
+ * Render a semantic item to one skeleton line.
111
+ */
112
+ export function renderItem(item) {
113
+ if (item.kind === "user") {
114
+ const elided = truncateMiddle(item.text, CAPS.shortUser.head, CAPS.shortUser.tail);
115
+ return `[U] 用户: ${elided}`;
116
+ }
117
+ if (item.kind === "tool") {
118
+ const args = truncateMiddle(item.args ?? "", CAPS.tool.head, CAPS.tool.tail);
119
+ return `[T] ${item.name}(${args})`;
120
+ }
121
+ if (item.kind === "result") {
122
+ const marker = item.ok ? "→ ok" : `→ error${item.errorCode ? ` (${item.errorCode})` : ""}`;
123
+ const extra = item.text === "" ? "" : ` ${truncateMiddle(item.text, 80, 40)}`;
124
+ return `[R] ${marker}${extra}`;
125
+ }
126
+ return "";
127
+ }
128
+
129
+ /**
130
+ * Build the compact transcript for the judge.
131
+ *
132
+ * @param opts - {
133
+ * events, session.events
134
+ * cfg, plugin config (uses transcriptMaxChars)
135
+ * denialHistory, Map<sessionId, Array<...>> — recent denials (≤5 kept)
136
+ * sessionId, for denial history lookup
137
+ * mode, tolerance, effective mode / risk tolerance lines
138
+ * mode3OnAsk, cwd, optional context lines
139
+ * }
140
+ * @returns the bounded transcript text; "" when there is nothing to show.
141
+ */
142
+ export function buildTranscript({ events, cfg, denialHistory, sessionId, mode, tolerance, mode3OnAsk, cwd } = {}) {
143
+ const maxChars = cfg?.transcriptMaxChars ?? 4000;
144
+ const items = collectSemanticItems(events);
145
+ if (items.length === 0) return "";
146
+
147
+ // Two-level window split (items are newest-first).
148
+ const firstUserIdx = items.findIndex((item) => item.kind === "user");
149
+ const shortItems = [];
150
+ const longUsers = [];
151
+ let userBudget = 0;
152
+ if (firstUserIdx !== -1) {
153
+ // Short window: the newest user message + up to 3 tool items around it.
154
+ shortItems.push(items[firstUserIdx]);
155
+ let tools = 0;
156
+ for (let i = firstUserIdx - 1; i >= 0 && tools < 3; i -= 1) {
157
+ if (items[i].kind === "tool") {
158
+ shortItems.push(items[i]);
159
+ tools += 1;
160
+ }
161
+ }
162
+ // Long window: every older user message (intent line), capped per entry.
163
+ for (let i = items.length - 1; i > firstUserIdx; i -= 1) {
164
+ if (items[i].kind === "user") {
165
+ longUsers.push(truncateMiddle(items[i].text, CAPS.longUser.head, CAPS.longUser.tail));
166
+ }
167
+ }
168
+ }
169
+ // Fallback: no user message at all (e.g. fresh session) — keep recent tools.
170
+ const fallbackTools = firstUserIdx === -1
171
+ ? items.filter((item) => item.kind === "tool" || item.kind === "result").slice(0, 4)
172
+ : [];
173
+
174
+ // Build sections in stable-prefix order (oldest first) for prefix caching.
175
+ const sections = [];
176
+ if (mode !== undefined) {
177
+ const modeLine = `mode: ${mode}${tolerance !== undefined ? `, tolerance: ${tolerance}` : ""}${mode3OnAsk !== undefined ? `, mode3OnAsk: ${mode3OnAsk}` : ""}`;
178
+ sections.push(`[M] ${modeLine}`);
179
+ }
180
+ if (cwd !== undefined && cwd !== "") sections.push(`[W] ${cwd}`);
181
+ for (const line of longUsers) sections.push(`[U] 用户: ${line}`);
182
+ for (const item of [...fallbackTools].reverse()) sections.push(renderItem(item));
183
+ for (const item of [...shortItems].reverse()) sections.push(renderItem(item));
184
+
185
+ // Denial history (P2, dropped first on overflow).
186
+ const denials = denialHistory?.get(sessionId) ?? [];
187
+ const denialLines = denials.slice(-3).map((d) => {
188
+ const src = d.source ?? "?";
189
+ const risk = d.risk !== undefined ? `, risk: ${d.risk}` : "";
190
+ const cmd = truncateMiddle(d.command ?? "", 80, 0);
191
+ return `[D] deny ${cmd} (${src}${risk})`;
192
+ });
193
+
194
+ // Budget: assemble; on overflow drop denial history, then oldest
195
+ // long-window entries, then oldest tool lines; final hard cut keeps the
196
+ // head and tail (mode line / newest user message are protected).
197
+ const joinLen = (lines) => lines.reduce((sum, line) => sum + line.length + 1, 0) - (lines.length > 0 ? 1 : 0);
198
+ let lines = [...sections, ...denialLines];
199
+ let text;
200
+ if (joinLen(lines) <= maxChars) {
201
+ text = lines.join("\n");
202
+ } else {
203
+ // 1) drop denial history entirely
204
+ lines = [...sections];
205
+ // 2) drop droppable lines from the oldest (top), protecting the first
206
+ // two lines ([M] mode / [W] cwd) and the last line (newest user).
207
+ while (joinLen(lines) > maxChars && lines.length > 3) {
208
+ lines.splice(2, 1);
209
+ }
210
+ text = lines.join("\n");
211
+ // 3) final hard cut — the elision marker costs ~12 chars itself, so
212
+ // shrink head/tail until the cut truly fits inside the cap.
213
+ let headChars = Math.floor(maxChars * 0.6);
214
+ let tailChars = Math.floor(maxChars * 0.3);
215
+ let cut = truncateMiddle(text, headChars, tailChars);
216
+ while (cut.length > maxChars && (headChars > 8 || tailChars > 4)) {
217
+ headChars = Math.floor(headChars * 0.8);
218
+ tailChars = Math.floor(tailChars * 0.8);
219
+ cut = truncateMiddle(text, headChars, tailChars);
220
+ }
221
+ if (cut.length > maxChars) cut = `${text.slice(0, Math.max(8, maxChars - 4))}…`;
222
+ text = cut;
223
+ }
224
+ return text.trim();
225
+ }