@chatpanel/events 0.65.0 → 0.66.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/tool-traits.js +57 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/events",
3
- "version": "0.65.0",
3
+ "version": "0.66.0",
4
4
  "description": "The canonical ChatPanel event-log and capability contracts \u2014 typed durable facts, clock-free deterministic linearization, schema upcasting, and the invariants the replay harness asserts. Pure, dependency-free ESM shared by the ChatPanel extension, gateway and bridge.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/tool-traits.js CHANGED
@@ -99,3 +99,60 @@ export function traitsIndex(specs = []) {
99
99
  for (const s of specs) if (s?.name) index.set(s.name, toolTraits(s));
100
100
  return index;
101
101
  }
102
+
103
+ /**
104
+ * Ask before a destructive call — the gate, as a toolset wrapper with the dialog injected.
105
+ *
106
+ * Page actions have had a confirmation card since the beginning; a tool on a connected
107
+ * server that DECLARES itself destructive (`destructiveHint`, or a name like `delete_repo`)
108
+ * had none: the model called it and it ran. This asks first, on the REAL tool name even
109
+ * when a dispatcher hides it, and refuses when there is nobody to ask — a background turn
110
+ * with no window cannot destroy anything a person did not approve.
111
+ *
112
+ * @param confirm async ({ name, input, via }) => 'allow' | 'always' | 'deny'. `always`
113
+ * allows this tool for the rest of the toolset's life (one session).
114
+ * Omit it on a surface with no window: destructive calls are then refused
115
+ * with a message the model can act on.
116
+ * @param only `(name) => boolean` — which top-level tools the gate covers (a host that
117
+ * already confirms its page and note tools passes its remote set).
118
+ * @param traitsOf `(name, input) => traits`; defaults to the toolset's index, then the name.
119
+ */
120
+ export function withDestructiveGate(toolset, { confirm = null, only = () => true, traitsOf = null, effectiveName = defaultEffectiveName } = {}) {
121
+ if (!toolset || typeof toolset.execute !== 'function') return toolset;
122
+ const base = toolset.execute.bind(toolset);
123
+ const byName = new Map((toolset.specs || []).filter((s) => s?.name).map((s) => [s.name, s]));
124
+ const allowed = new Set();
125
+ const traits = traitsOf || ((name, input) => {
126
+ const eff = effectiveName(name, input);
127
+ return toolset.traits?.get(eff) || toolset.traits?.get(name) || toolTraits(byName.get(eff) || byName.get(name) || { name: eff });
128
+ });
129
+ return {
130
+ ...toolset,
131
+ async execute(name, input, meta) {
132
+ if (!only(name)) return base(name, input, meta);
133
+ const t = traits(name, input);
134
+ if (!needsConfirmation(t)) return base(name, input, meta);
135
+ const eff = effectiveName(name, input);
136
+ if (allowed.has(eff)) return base(name, input, meta);
137
+ if (!confirm) {
138
+ return JSON.stringify({
139
+ error: `"${eff}" is a destructive action and needs the user's confirmation, which this surface cannot ask for. Do not retry it; tell the user what you would do and let them run it from the side panel.`,
140
+ blocked: true, needsConfirmation: true, tool: eff,
141
+ });
142
+ }
143
+ const decision = await confirm({ name: eff, input, via: name === eff ? null : name, traits: t });
144
+ if (decision === 'always') allowed.add(eff);
145
+ if (decision !== 'allow' && decision !== 'always') {
146
+ return JSON.stringify({ error: `The user DECLINED "${eff}". Do not retry it — stop and ask the user how to proceed.`, blocked: true, declined: true, tool: eff });
147
+ }
148
+ return base(name, input, meta);
149
+ },
150
+ };
151
+ }
152
+
153
+ // A dispatcher carries the real action in `input.action`; the gate must see through it or
154
+ // `mcp {action:"mcp_x__delete_repo"}` is judged by the name "mcp".
155
+ function defaultEffectiveName(name, input) {
156
+ const action = input && typeof input === 'object' ? input.action : null;
157
+ return typeof action === 'string' && action ? action : name;
158
+ }