@milpa/comal 0.2.6 → 0.2.8

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/src/house.mjs CHANGED
@@ -17,6 +17,12 @@ const savedBroadAllow = (rules, name) => !rules?.decide?.(name, {})?.list?.match
17
17
  });
18
18
 
19
19
 
20
+ // Refusals whose source is the human's own No to a permission question; the receipt says so.
21
+ const humanRefusals = new WeakSet();
22
+ // The first receipt that recorded an error object, and the invocation it would propagate to next. Only that
23
+ // direct parent, rethrowing it in the same book, says so (relayed_from); an unrelated later throw does not.
24
+ const receiptedErrors = new WeakMap();
25
+
20
26
  // Textos de la tarjeta de confirmación: sólo los ve el humano.
21
27
  registerMessages({
22
28
  'Could not verify the summary': 'No se pudo verificar el resumen',
@@ -54,7 +60,9 @@ export class House {
54
60
  #scopeMechanisms = new Map();
55
61
  #revokedScopes = new Set();
56
62
  constructor({ cwd, registry = new OperationRegistry(), record }) { this.cwd = cwd; this.registry = registry; this.hasRecorder = Boolean(record); this.record = record || (async () => {}); }
57
- snapshot(rows) { return houseSnapshot(rows, { active: this.#active, exclude: this.#context.getStore()?.id }); }
63
+ // The host may attach the finding recorder's swallowed count: () => number.
64
+ findingsUnrecorded = null;
65
+ snapshot(rows) { return houseSnapshot(rows, { active: this.#active, exclude: this.#context.getStore()?.id, findingsUnrecorded: this.findingsUnrecorded?.() }); }
58
66
  stateContext() { return { active: new Set(this.#active), exclude: this.#context.getStore()?.id }; }
59
67
  recoveryAdapter(sessionId,name) {return this.#recoveries.get(`${sessionId}:${name}`);}
60
68
  registerRecovery(sessionId,name,handler) {const key=`${sessionId}:${name}`;this.#recoveries.set(key,handler);return ()=>{if(this.#recoveries.get(key)===handler)this.#recoveries.delete(key);};}
@@ -292,7 +300,7 @@ export class House {
292
300
  const active = this.#context.getStore();
293
301
  await this.emit(context, { type: 'house_decision', invocation_id: active?.id ?? null, contract_digest: active?.contractDigest ?? null, operation: name, arguments_digest: digest(input), allowed: Boolean(decision.allowed), reason: decision.reason, mode: context.policy.mode, ...(['permission_rule', 'confirm_always'].includes(decision.source) ? { permission_source: decision.source, ...(typeof decision.rule === 'string' ? { rule: decision.rule } : {}) } : {}) });
294
302
  if (active) active.decisions.push(Boolean(decision.allowed));
295
- if (!decision.allowed) { throw hostRefusalFailure(`Permission denied: ${name}: ${decision.reason}`, 'HOUSE_REFUSED', 'permission', {type:'house_decision'}); }
303
+ if (!decision.allowed) { const error = hostRefusalFailure(`Permission denied: ${name}: ${decision.reason}`, 'HOUSE_REFUSED', 'permission', {type:'house_decision'}); if (decision.refused_by === 'human') humanRefusals.add(error); throw error; }
296
304
  signal?.throwIfAborted(); return decision;
297
305
  }
298
306
  async execute(context, catalog, name, input, { signal, callId, work, beforeEffect, modelProposed = false, modelSurface } = {}) {
@@ -395,8 +403,11 @@ export class House {
395
403
  });
396
404
  } catch (error) {
397
405
  const refusalCause=settleNativeRefusal(error,{...base,house:this.cwd,session_id:context.sessionId});
398
- await this.emit(context, { type: 'house_receipt', ...base, ...(refusalCause?{refusal_cause:refusalCause}:{}), status: entered ? 'failed' : 'refused', disposition: ['HOUSE_STALE', 'HOUSE_POSTCONDITION', 'HOUSE_AUTHORITY_CHANGED', 'HOUSE_SURFACE_CHANGED'].includes(error.code) ? 'repeat' : 'basket', judgment_scope: 'house_execution', code: error.code, error: error.message, effects_observed: entered ? 'unknown_or_partial' : 'none', observations: state.observations, asked: 'unjudged', ...(state.confirmCard ? { confirm_card: state.confirmCard } : {}) });
406
+ const keyed = error !== null && (typeof error === 'object' || typeof error === 'function'), first = keyed ? receiptedErrors.get(error) : undefined;
407
+ const relayedFrom = first && first.session_id === context.sessionId && first.next === id ? first.invocation_id : undefined;
408
+ await this.emit(context, { type: 'house_receipt', ...base, ...(refusalCause?{refusal_cause:refusalCause}:{}), ...(humanRefusals.has(error) ? { refused_by: 'human' } : {}), ...(relayedFrom ? { relayed_from: relayedFrom } : {}), status: entered ? 'failed' : 'refused', disposition: ['HOUSE_STALE', 'HOUSE_POSTCONDITION', 'HOUSE_AUTHORITY_CHANGED', 'HOUSE_SURFACE_CHANGED'].includes(error.code) ? 'repeat' : 'basket', judgment_scope: 'house_execution', code: error.code, error: error.message, effects_observed: entered ? 'unknown_or_partial' : 'none', observations: state.observations, asked: 'unjudged', ...(state.confirmCard ? { confirm_card: state.confirmCard } : {}) });
399
409
  receiptRecorded = true;
410
+ if (keyed) receiptedErrors.set(error, { invocation_id: relayedFrom ?? id, session_id: context.sessionId, next: base.parent_invocation_id });
400
411
  throw error;
401
412
  } finally {
402
413
  // Host-owned resources outlive the handler and its durable outcome. A
@@ -408,7 +419,7 @@ export class House {
408
419
  }
409
420
 
410
421
  // Derive closure from executor events, never from tool JSON saying verified=true.
411
- export function houseSnapshot(rows, { active = new Set(), exclude } = {}) {
422
+ export function houseSnapshot(rows, { active = new Set(), exclude, findingsUnrecorded } = {}) {
412
423
  const started = new Map(), receipts = [];
413
424
  for (const row of rows) {
414
425
  if (row.invocation_id === exclude && exclude) continue;
@@ -418,5 +429,5 @@ export function houseSnapshot(rows, { active = new Set(), exclude } = {}) {
418
429
  const counts = Object.fromEntries(['returned', 'failed', 'refused'].map(status => [status, receipts.filter(r => r.status === status).length]));
419
430
  const permissionRefusals = rows.filter(row => row.type === 'house_decision' && !row.allowed).length;
420
431
  const unresolved = [...started.keys()].filter(id => !active.has(id)), running = [...started.keys()].filter(id => active.has(id));
421
- return { scope: 'house_execution', asked: 'unjudged', status: unresolved.length ? 'interrupted' : running.length ? 'running' : counts.failed || counts.refused || permissionRefusals ? 'needs_review' : receipts.length ? 'recorded' : 'no_receipts', counts, permission_refusals: permissionRefusals, unresolved, running, receipts: receipts.slice(-40).map(({ output, ...row }) => row), receipt_total: receipts.length, integrity: 'local_journal_not_signed' };
432
+ return { scope: 'house_execution', asked: 'unjudged', status: unresolved.length ? 'interrupted' : running.length ? 'running' : counts.failed || counts.refused || permissionRefusals ? 'needs_review' : receipts.length ? 'recorded' : 'no_receipts', counts, permission_refusals: permissionRefusals, unresolved, running, receipts: receipts.slice(-40).map(({ output, ...row }) => row), receipt_total: receipts.length, ...(Number.isInteger(findingsUnrecorded) && findingsUnrecorded > 0 ? { findings_unrecorded: findingsUnrecorded } : {}), integrity: 'local_journal_not_signed' };
422
433
  }
@@ -1,6 +1,6 @@
1
1
  // Original host messages for the optional Spanish interface. No user/model content.
2
2
  export default Object.freeze({
3
- "\nFrom inside a session (no flags needed):\n /config [set KEY VALUE|save] · /max-tokens [N] · /context-window [auto|N] · /raw\n /login PROVIDER [--key|--api-key-env VARIABLE] (hidden key field; ollama = Ollama Cloud)\n /plugins available|add ID [k=v…]|config ID [k=v…]|remove ID|trust|untrust ID|allow ID observe|all|none|OP…\n /chrome-workflow [auto|raw] · /autonomy [observe|assist|edit|autonomous]\n /permissions [MODE|allow|ask|deny|remove RULE [--sesion|--proyecto|--usuario]]\n": "\nDesde dentro de la sesión (sin banderas):\n /config [set CLAVE VALOR|save] · /max-tokens [N] · /context-window [auto|N] · /raw\n /login PROVEEDOR [--key|--api-key-env VARIABLE] (campo oculto para la llave; ollama = Ollama Cloud)\n /plugins available|add ID [k=v…]|config ID [k=v…]|remove ID|trust|untrust ID|allow ID observe|all|none|OP…\n /chrome-workflow [auto|raw] · /autonomy [observar|asistir|editar|autonomo]\n /permissions [MODO|allow|ask|deny|remove REGLA [--sesion|--proyecto|--usuario]]\n",
3
+ "\nFrom inside a session (no flags needed):\n /config [set KEY VALUE|save] · /max-tokens [N] · /context-window [auto|N] · /raw\n /login PROVIDER [--key|--api-key-env VARIABLE] (hidden key field; ollama = Ollama Cloud)\n /plugins available|add ID [k=v…]|config ID [k=v…]|remove ID|trust|untrust ID|allow ID observe|all|none|OP…|repairs|acts|revert ID\n /chrome-workflow [auto|raw] · /autonomy [observe|assist|edit|autonomous]\n /permissions [MODE|allow|ask|deny|remove RULE [--sesion|--proyecto|--usuario]]\n": "\nDesde dentro de la sesión (sin banderas):\n /config [set CLAVE VALOR|save] · /max-tokens [N] · /context-window [auto|N] · /raw\n /login PROVEEDOR [--key|--api-key-env VARIABLE] (campo oculto para la llave; ollama = Ollama Cloud)\n /plugins available|add ID [k=v…]|config ID [k=v…]|remove ID|trust|untrust ID|allow ID observe|all|none|OP…|repairs|acts|revert ID\n /chrome-workflow [auto|raw] · /autonomy [observar|asistir|editar|autonomo]\n /permissions [MODO|allow|ask|deny|remove REGLA [--sesion|--proyecto|--usuario]]\n",
4
4
  "Permission bypass is fixed for this process. Restart Comal without --dangerously-skip-permissions to change modes.": "El bypass de permisos queda fijo en este proceso. Reinicia Comal sin --dangerously-skip-permissions para cambiar de modo.",
5
5
  "Permission bypass active: prompts and classifier skipped for this process.": "Bypass de permisos activo: este proceso omite confirmaciones y clasificador.",
6
6
  "\n --dangerously-skip-permissions Skip permission prompts/classifier for this process; startup only. Explicit denials and operation contracts still apply.\n": "\n --dangerously-skip-permissions Omite confirmaciones y clasificador en este proceso; sólo al arrancar. Se conservan denegaciones explícitas y contratos.\n",
package/src/i18n/es.mjs CHANGED
@@ -160,6 +160,8 @@ export default Object.freeze({
160
160
  "vision": "visión",
161
161
  "connecting": "conectando",
162
162
  "Enlarge the terminal to 24×10": "Amplía el terminal a 24×10",
163
+ "Permission: {v0}": "Permiso: {v0}",
164
+ "Waiting for your answer": "Esperando tu respuesta",
163
165
  " {v0} interrupted message(s); not resent. R requeues the first.": " {v0} mensaje(s) interrumpidos; no se reenviaron. R reencola el primero.",
164
166
  " Empty queue. Send a message while the agent works.": " Cola vacía. Envía un mensaje mientras el agente trabaja.",
165
167
  " Running: ": " En ejecución: ",
@@ -482,6 +484,26 @@ export default Object.freeze({
482
484
  "{run} run · {blocked} blocked · {done} done": "{run} en curso · {blocked} bloq. · {done} hechas",
483
485
  "{q} questions · {p} plans": "{q} preguntas · {p} planes",
484
486
  "/swarm opens": "/swarm lo abre",
485
- "Comal is working · Esc to see the conversation": "Comal está trabajando · Esc para ver la conversación"
487
+ "Comal is working · Esc to see the conversation": "Comal está trabajando · Esc para ver la conversación",
488
+ "Plugin {plugin}: the repair changes what it promises. Sign only if you accept the new contract.": "Plugin {plugin}: la reparación cambia lo que promete. Firma sólo si aceptas el contrato nuevo.",
489
+ "Plugin {plugin}: the repair keeps what it promises, but it holds code the house cannot follow by reading it. Sign only if you reviewed that code.": "Plugin {plugin}: la reparación mantiene lo que promete, pero contiene código que la casa no puede seguir leyéndolo. Firma sólo si revisaste ese código.",
490
+ "Code the house cannot follow: {list}": "Código que la casa no puede seguir: {list}",
491
+ "Surface changed: {list}": "Superficie cambiada: {list}",
492
+ "Cases removed: {list}": "Casos eliminados: {list}",
493
+ "Cases changed: {list}": "Casos cambiados: {list}",
494
+ "Cases added: {list}": "Casos añadidos: {list}",
495
+ "The model's reason: {reason}": "Motivo del modelo: {reason}",
496
+ "Files in the patch: {list}": "Archivos del parche: {list}",
497
+ "Yes signs the act and the repaired plugin is published and checked; No (the default) leaves everything as it is.": "Sí firma el acta y el plugin reparado se publica y se comprueba; No (la opción por defecto) deja todo como está.",
498
+ "(none)": "(ninguno)",
499
+ "(none given)": "(no dio ninguno)",
500
+ "before: {value}": "antes: {value}",
501
+ "after: {value}": "después: {value}",
502
+ "{count} item(s) truncated; the full contracts before and after are in {before} and {after}.": "{count} elemento(s) recortado(s); los contratos completos de antes y después están en {before} y {after}.",
503
+ "An interrupted plugin repair could not be recovered ({error}); its marker was left in place and plugins.json was not changed. Review it with /plugins.": "No se pudo recuperar una reparación de plugin interrumpida ({error}); su marcador se dejó en su sitio y plugins.json no se cambió. Revísalo con /plugins.",
504
+ "The working directory {cwd} is inside a private Comal location ({location}). File tools (Read, Write, Edit, Grep…) are refused here: Comal keeps its configuration, credentials and session state out of the model's reach. Start Comal in your project directory, or choose another --state-dir.": "El directorio de trabajo {cwd} está dentro de una ubicación privada de Comal ({location}). Las herramientas de archivos (Read, Write, Edit, Grep…) se rechazan aquí: Comal mantiene su configuración, credenciales y estado de sesión fuera del alcance del modelo. Inicia Comal en el directorio de tu proyecto, o elige otro --state-dir.",
505
+ "A plugin repair was interrupted before it was verified; plugins.json was put back as it was before it.": "Una reparación de plugin se interrumpió antes de verificarse; plugins.json volvió a como estaba antes.",
506
+ "An unreadable plugin repair marker was removed; plugins.json was not changed.": "Se eliminó una marca ilegible de reparación de plugin; plugins.json no cambió.",
507
+ "A plugin repair was interrupted, but plugins.json was edited since; it was left as it is. Review it with /plugins.": "Una reparación de plugin se interrumpió, pero plugins.json se editó después; se dejó como está. Revísalo con /plugins."
486
508
 
487
509
  });
@@ -8,12 +8,22 @@ export const digest = value => createHash('sha256').update(JSON.stringify(canoni
8
8
  export const immutable = value => { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(immutable); Object.freeze(value); } return value; };
9
9
  const validatorOptions = { strict: false, allErrors: true, validateFormats: false };
10
10
 
11
+ // Repair by contract (plugin-repair.mjs): offered to the root agent only, never to a subagent's surface.
12
+ export const PLUGIN_REPAIR_OPERATION_NAMES = Object.freeze(['PluginRepairReproduce', 'PluginRepairOpen', 'PluginRepairTest', 'PluginRepairAccept', 'PluginRepairDiscard']);
13
+
11
14
  // An effect ceiling describes declared reach, never proof of actual remote effects.
12
15
  export function toolEffects(name) {
13
16
  if (['ListDirectory', 'FileInfo'].includes(name)) return { workspace: 'read', external: 'none', process: 'none', authority: 'none' };
14
17
  if (['Read', 'Glob', 'Grep', 'McpStatus', 'HouseContext', 'HouseSnapshot', 'OperationCatalog', 'ReadTable', 'PluginStatus', 'WorkSnapshot', 'AuditStatus', 'EvidenceRead'].includes(name)) return { workspace: 'read', external: 'none', process: 'none', authority: 'none' };
15
18
  if (['Write', 'Edit'].includes(name)) return { workspace: 'write', external: 'none', process: 'none', authority: 'none' };
16
19
  if (['WebSearch', 'WebFetch'].includes(name)) return { workspace: 'none', external: 'read', process: 'none', authority: 'none' };
20
+ // Repair by contract (plugin-repair.mjs). Reproduce calls a plugin operation, whose own effects are not
21
+ // known statically; Test runs candidate code in a child process; Accept writes plugins.json and the trust
22
+ // record and loads code in this process; Open and Discard create or remove the candidate's write root.
23
+ if (name === 'PluginRepairReproduce') return { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' };
24
+ if (name === 'PluginRepairOpen' || name === 'PluginRepairDiscard') return { workspace: 'write', external: 'none', process: 'none', authority: 'candidate_write_root' };
25
+ if (name === 'PluginRepairTest') return { workspace: 'unknown', external: 'unknown', process: 'spawn', authority: 'none' };
26
+ if (name === 'PluginRepairAccept') return { workspace: 'write', external: 'unknown', process: 'unknown', authority: 'plugin_lifecycle' };
17
27
  return { workspace: 'unknown', external: 'unknown', process: 'unknown', authority: 'none' };
18
28
  }
19
29
 
@@ -268,11 +268,12 @@ export class PermissionRules {
268
268
  return ruleCovers(parsed, name, input, { list, project: this.project || process.cwd(), groups: this.groups });
269
269
  }
270
270
  /** Precedencia: deny > ask > allow. Devuelve la regla decisiva o undefined. */
271
- decide(name, input) {
271
+ // context: { invocation } of the call being judged, passed to gate (it never changes deny or ask).
272
+ decide(name, input, context) {
272
273
  const found = this.applicable().filter(rule => this.matches(rule, name, input));
273
274
  const deny = found.find(rule => rule.list === 'deny'); if (deny) return deny;
274
275
  const ask = found.find(rule => rule.list === 'ask'); if (ask) return ask;
275
- return found.find(rule => rule.list === 'allow' && this.gate(name));
276
+ return found.find(rule => rule.list === 'allow' && this.gate(name, context));
276
277
  }
277
278
  /** Reemplaza las reglas que cumplen `remove` por `add`, en una sola escritura atómica. */
278
279
  async update({ remove = () => false, add = [] } = {}) {
@@ -7,9 +7,18 @@ import { readOnlyBashCheck, chromeLabObservation, openRelayObservation, omarchyO
7
7
  import { describeRule, suggestRule } from './permission-rules.mjs';
8
8
  import { measureHumanWait } from './turn-timing.mjs';
9
9
  import { realpath, stat } from 'node:fs/promises';
10
+ import { realpathSync } from 'node:fs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
12
13
 
14
+ // The same, synchronously, for the policy's construction.
15
+ function canonicalishSync(file) {
16
+ let ancestor = path.resolve(file), suffix = [];
17
+ while (true) {
18
+ try { return path.join(realpathSync(ancestor), ...suffix); }
19
+ catch { const parent = path.dirname(ancestor); if (parent === ancestor) return path.resolve(file); suffix.unshift(path.basename(ancestor)); ancestor = parent; }
20
+ }
21
+ }
13
22
  const within = (root, file) => { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); };
14
23
 
15
24
  /** realpath of the nearest existing ancestor plus the rest: a location not created yet still compares canonically. */
@@ -114,15 +123,22 @@ function normalizeAnswer(value, offer) {
114
123
  return { allowed: Boolean(value), remember: null };
115
124
  }
116
125
  const CONFIRM_ALWAYS = 'confirm:"always"';
126
+ const humanRefusal = answer => answer.refused_by === 'human' ? { refused_by: 'human' } : {};
117
127
 
118
128
  export class PermissionPolicy {
119
129
  #mode;
120
130
  // rules: almacén de reglas guardadas (permission-rules.mjs) con decide(name, input) → {list, rule, scope}.
121
131
  // contractOf(name): contrato vivo de la operación (house.registry.get(name)?.contract); de ahí sale
122
132
  // el techo duro confirm:'always', que sólo el host lee y el modelo no puede cambiar.
123
- #workRoots = []; #private = []; #privateExtra;
124
- constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {}, readRoots = [], privateLocations: privateExtra = [] } = {}) {
133
+ #workRoots = []; #private = []; #privateExtra; #candidateRoots = []; #ownWorkspace = null;
134
+ constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {}, readRoots = [], privateLocations: privateExtra = [], ownWorkspace } = {}) {
125
135
  this.#privateExtra = [...privateExtra];
136
+ // Host-only (scoped-policy.mjs): the workspace the house created for a child inside one of the extra private
137
+ // locations (an owned Git worktree under the --state-dir). That tree, and only it, is not private for this
138
+ // policy; the rest of the location still is. Comal's standard private locations are never exempted.
139
+ if (typeof ownWorkspace === 'string' && ownWorkspace) this.#ownWorkspace = { root: canonicalishSync(ownWorkspace), locations: this.#privateExtra.flatMap(location => [path.resolve(location), canonicalishSync(location)]) };
140
+ // Private from the start, for every path the tools resolve (the --state-dir included, wherever it is).
141
+ for (const location of privateLocations({ extra: this.#privateExtra })) this.#private.push(location, canonicalishSync(location));
126
142
  if (!PERMISSION_MODES.includes(mode) && !(dangerouslySkipPermissions === true && mode === BYPASS_PERMISSION_MODE)) throw new Error('Invalid permission mode. Use default, plan, acceptEdits or auto; bypass requires the startup flag.');
127
143
  Object.defineProperty(this, 'dangerouslySkipPermissions', { value: dangerouslySkipPermissions === true, enumerable: true });
128
144
  this.#mode = this.dangerouslySkipPermissions ? BYPASS_PERMISSION_MODE : mode;
@@ -147,9 +163,53 @@ export class PermissionPolicy {
147
163
  return { added: true, path: canonical };
148
164
  }
149
165
  /** Host-only: another session was resumed in this process; its own /add-dir rows are restored next. */
150
- clearWorkRoots() { this.#workRoots = []; }
166
+ clearWorkRoots() { this.#workRoots = []; this.#candidateRoots = []; }
167
+ /**
168
+ * Host-only (plugin-repair.mjs): the exact tree of an open plugin candidate, `<container>/<id>/<uuid>/tree`
169
+ * where container is `<stateDir>/plugin-candidates`. It is the one writable place under Comal state: inside
170
+ * it, and only there, the .comal/.harness rule and the state directory's own privacy (--state-dir) do not
171
+ * apply. Any other private Comal location still refuses it. Removed with removeCandidateRoot.
172
+ */
173
+ async addCandidateRoot(dir, { container, env, home } = {}) {
174
+ if (typeof dir !== 'string' || typeof container !== 'string') throw new TypeError('addCandidateRoot requires the candidate tree and its container.');
175
+ const canonical = await realpath(dir), box = await realpath(container), info = await stat(canonical);
176
+ const parts = path.relative(box, canonical).split(path.sep);
177
+ if (!info.isDirectory() || parts.length !== 3 || parts[2] !== 'tree' || parts.some(part => !part || part === '..') || path.isAbsolute(path.relative(box, canonical))) throw new Error(`Not a plugin candidate tree: ${dir}`);
178
+ if (info.mode & 0o002) throw new Error(`The candidate tree is world-writable: ${canonical}`);
179
+ const stateDir = path.dirname(box), exempt = [];
180
+ for (const location of privateLocations({ env, home, extra: this.#privateExtra })) {
181
+ const real = await canonicalish(location);
182
+ if ([location, real].includes(stateDir)) { exempt.push(location, real); continue; } // the state directory itself (--state-dir)
183
+ if ([location, real].some(item => within(canonical, item) || within(item, canonical))) throw new Error(`The candidate tree touches a private Comal location (${location}).`);
184
+ }
185
+ this.#private = [];
186
+ for (const location of privateLocations({ env, home, extra: this.#privateExtra })) this.#private.push(location, await canonicalish(location));
187
+ if (!this.#candidateRoots.some(entry => entry.root === canonical)) { this.#candidateRoots.push({ root: canonical, exempt }); this.#workRoots.push(canonical); }
188
+ return { added: true, path: canonical };
189
+ }
190
+ removeCandidateRoot(dir) {
191
+ const entry = this.#candidateRoots.find(item => item.root === dir); if (!entry) return false;
192
+ this.#candidateRoots = this.#candidateRoots.filter(item => item !== entry); this.#workRoots = this.#workRoots.filter(root => root !== entry.root); return true;
193
+ }
194
+ /** The open candidate trees (canonical paths; a frozen copy). */
195
+ get candidateRoots() { return Object.freeze(this.#candidateRoots.map(entry => entry.root)); }
196
+ /** The open candidate tree holding `file` (canonical), or null. */
197
+ candidateRootOf(file) { return this.#candidateRoots.find(entry => within(entry.root, file))?.root ?? null; }
198
+ /** The private locations given at construction (the --state-dir and the other paths named by flags): a scoped policy takes them too. */
199
+ get privateExtra() { return Object.freeze([...this.#privateExtra]); }
200
+ /** The private Comal location that holds `file` (canonical), or null. An open candidate's tree is not inside one. */
201
+ privateLocationOf(file) {
202
+ const candidate = this.#candidateRoots.find(entry => within(entry.root, file));
203
+ return this.#private.find(location => within(location, file) && !candidate?.exempt.includes(location) && !this.#own(location, file)) ?? null;
204
+ }
205
+ /** The workspace the house gave this policy inside a private location (see the constructor), or undefined. */
206
+ get ownWorkspace() { return this.#ownWorkspace?.root; }
207
+ #own(location, file) { return Boolean(this.#ownWorkspace) && this.#ownWorkspace.locations.includes(location) && within(this.#ownWorkspace.root, file); }
151
208
  /** A private Comal location or a path an active plugin protects: closed even under an added directory. */
152
- isPrivate(file) { return this.#private.some(location => within(location, file)) || isProtectedPath(file); }
209
+ isPrivate(file) {
210
+ const candidate = this.#candidateRoots.find(entry => within(entry.root, file));
211
+ return this.#private.some(location => within(location, file) && !candidate?.exempt.includes(location) && !this.#own(location, file)) || isProtectedPath(file);
212
+ }
153
213
  get mode() { return this.#mode; }
154
214
  set mode(mode) {
155
215
  if (this.dangerouslySkipPermissions) throw new Error(BYPASS_LOCKED_MESSAGE);
@@ -179,16 +239,16 @@ export class PermissionPolicy {
179
239
  signal?.throwIfAborted();
180
240
  if (this.denied.has(name)) return { allowed: false, reason: 'Explicit denial rule' };
181
241
  // Saved human rules: deny > ask > allow. Ask forces a question even in auto; the startup bypass skips it (deny still applies).
182
- const rule = this.rules?.decide(name, input), ruled = rule ? `Permission ${describeRule(rule)}` : null, cite = rule ? { rule: rule.rule } : {};
242
+ const rule = this.rules?.decide(name, input, { invocation }), ruled = rule ? `Permission ${describeRule(rule)}` : null, cite = rule ? { rule: rule.rule } : {};
183
243
  if (rule?.list === 'deny') return { allowed: false, reason: ruled, source: 'permission_rule', ...cite };
184
244
  if (this.mode === BYPASS_PERMISSION_MODE) return { allowed: true, reason: 'Explicit startup --dangerously-skip-permissions; permission prompts and classifier skipped', source: 'startup_bypass' };
185
245
  if (this.confirmAlways(name)) {
186
246
  if (this.mode === 'plan') return { allowed: false, reason: `Plan mode: la operación declara ${CONFIRM_ALWAYS} y plan no la ejecuta`, source: 'confirm_always' };
187
247
  if (!this.confirm) return { allowed: false, reason: `La operación declara ${CONFIRM_ALWAYS} y no hay un humano que confirme`, source: 'confirm_always' };
188
248
  const answer = await this.#question(name, input, signal, onWaiting, null); if (answer.stale) return answer;
189
- return { allowed: answer.allowed, reason: `Confirmación obligatoria (${CONFIRM_ALWAYS}): ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'confirm_always' };
249
+ return { allowed: answer.allowed, reason: `Confirmación obligatoria (${CONFIRM_ALWAYS}): ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'confirm_always', ...humanRefusal(answer) };
190
250
  }
191
- if (rule?.list === 'ask') { const answer = await this.#question(name, input, signal, onWaiting, null); if (answer.stale) return answer; return { allowed: answer.allowed, reason: `${ruled}: ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'permission_rule', ...cite }; }
251
+ if (rule?.list === 'ask') { const answer = await this.#question(name, input, signal, onWaiting, null); if (answer.stale) return answer; return { allowed: answer.allowed, reason: `${ruled}: ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'permission_rule', ...cite, ...humanRefusal(answer) }; }
192
252
  const granted = this.allowed.has(name) || rule?.list === 'allow';
193
253
  const knownRead = READ_TOOLS.has(name) || granted && (chromeLabObservation(name) || openRelayObservation(name) || omarchyObservation(name));
194
254
  if (name === 'Agent' || name === 'AgentBatch') return { allowed: true, reason: 'Delegation: every child action retains the session permissions' };
@@ -203,7 +263,7 @@ export class PermissionPolicy {
203
263
  const offer = this.rememberOffer(name, input), answer = await this.#question(name, input, signal, onWaiting, offer);
204
264
  if (answer.stale) return answer;
205
265
  const base = reason ?? (answer.allowed ? 'User confirmation' : 'No authorization for this tool');
206
- return { allowed: answer.allowed, reason: answer.saved ? `${base}; saved ${describeRule(answer.saved)}` : answer.saveError ? `${base}; the rule was not saved: ${answer.saveError}` : base, ...(answer.saved ? { source: 'permission_rule', rule: answer.saved.rule } : {}) };
266
+ return { allowed: answer.allowed, reason: answer.saved ? `${base}; saved ${describeRule(answer.saved)}` : answer.saveError ? `${base}; the rule was not saved: ${answer.saveError}` : base, ...(answer.saved ? { source: 'permission_rule', rule: answer.saved.rule } : {}), ...humanRefusal(answer) };
207
267
  }
208
268
  /** Regla propuesta para «sí, siempre» o null (sin almacén, confirm:'always' o sin regla segura). */
209
269
  rememberOffer(name, input) {
@@ -219,7 +279,9 @@ export class PermissionPolicy {
219
279
  const slot=new Promise(resolve => {release=resolve;});
220
280
  const tail=previous.catch(() => {}).then(() => slot);
221
281
  confirmationQueues.set(confirm,tail);
222
- const questionId=randomUUID();let allowed=false,displayed=false;
282
+ // Why the card closed comes back out of band (onOutcome), never in confirm's value: callers of confirm
283
+ // test its truthiness, so it stays a boolean (or { allowed: true, remember } when «always» was offered).
284
+ const questionId=randomUUID();let allowed=false,displayed=false,outcome;
223
285
  try {
224
286
  await waitWithAbort(previous,signal);
225
287
  signal?.throwIfAborted();
@@ -230,9 +292,12 @@ export class PermissionPolicy {
230
292
  await onWaiting?.({type:'house_waiting',question_id:questionId});
231
293
  displayed=true;signal?.throwIfAborted();
232
294
  // The visible card is human time: excluded from the turn's Comal time.
233
- const answer=normalizeAnswer(await measureHumanWait(()=>waitWithAbort(confirm(name,input,{signal,questionId,...(offer?{remember:offer}:{})}),signal)),offer);
295
+ const answer=normalizeAnswer(await measureHumanWait(()=>waitWithAbort(confirm(name,input,{signal,questionId,onOutcome:why=>{outcome=why;},...(offer?{remember:offer}:{})}),signal)),offer);
234
296
  signal?.throwIfAborted();
235
297
  allowed=answer.allowed && this.confirm===confirm && (stamp===permissionStamp(this) || visible===houseStamp(this) && this.rules?.decide(name,input)?.list!=='deny');
298
+ // Only the human's plain No (outcome 'no') marks the refusal; edit before sending, a closed
299
+ // screen, an abort, an interrupt and a stale or changed authority do not.
300
+ if(!answer.allowed && outcome==='no')return { allowed:false, refused_by: 'human' };
236
301
  if(!allowed || !answer.remember)return { allowed };
237
302
  // Se guarda antes de liberar el turno, para que la siguiente pregunta ya la vea.
238
303
  const scope=answer.remember==='user' ? 'user' : this.rules.scope;
@@ -0,0 +1,204 @@
1
+ // The child side of the plugin workbench runner (plugin-candidate-runner.mjs). It runs in
2
+ // its own process and process group, started as
3
+ // node --max-old-space-size=512 plugin-candidate-child.mjs <candidate module>
4
+ // and speaks JSON lines: requests on stdin, reports on the real stdout. Candidate code
5
+ // writing through console.log or process.stdout.write is sent to stderr instead, so a noisy
6
+ // plugin is not a protocol error; bytes written straight to fd 1 still reach the parent and
7
+ // count against its protocol bound.
8
+ //
9
+ // Nothing here is a security boundary against the candidate: candidate code runs in this
10
+ // same process and can write anything it likes, forged reports included. The parent treats
11
+ // every message as untrusted, bounded data; isolation protects the session, not the truth
12
+ // of the report.
13
+ //
14
+ // The recording context mirrors PluginRuntime#stage (src/plugins.mjs) and the manifest checks
15
+ // mirror PluginRuntime#definition: both are private to PluginRuntime, so the minimum is
16
+ // replicated here. Operations are built with the live Operation class, so schema
17
+ // compilation and input/output validation are the live ones.
18
+ import { pathToFileURL } from 'node:url';
19
+ import { createInterface } from 'node:readline';
20
+ import { writeSync } from 'node:fs';
21
+ import { Operation, immutable, toolEffects, digest } from './operations.mjs';
22
+ import { validateCases } from './plugin-contract.mjs';
23
+
24
+ // Reports are written synchronously to fd 1, so a process.exit right after one loses nothing.
25
+ const toStderr = process.stderr.write.bind(process.stderr);
26
+ process.stdout.write = (chunk, encoding, callback) => toStderr(chunk, encoding, callback);
27
+ function send(message) {
28
+ const bytes = Buffer.from(`${JSON.stringify(message)}\n`);
29
+ for (let offset = 0; offset < bytes.length;) {
30
+ try { offset += writeSync(1, bytes, offset); } catch (error) { if (error.code !== 'EAGAIN') throw error; }
31
+ }
32
+ }
33
+
34
+ const TEXT = 2000, MAX_OPERATIONS = 200, MAX_CHECKS = 200, OUTPUT_BYTES = 256 * 1024, OUTPUT_TOTAL = 2 * 1024 * 1024;
35
+ const text = value => String(value ?? '').slice(0, TEXT);
36
+ const errorText = error => text(error instanceof Error ? error.message : error);
37
+ const jsonCopy = value => JSON.parse(JSON.stringify(value));
38
+ const idPattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
39
+ // operationName in plugin-catalog.mjs; not imported, since that module loads the bundled plugins.
40
+ const published = (id, name) => name.startsWith('plugin__') ? name : `plugin__${id}__${name}`;
41
+ const HOOK_EVENTS = ['operation:result', 'operation:decision', 'skill:started', 'skill:completed'];
42
+ const OPERATION_KEYS = ['name', 'description', 'input_schema', 'output_schema', 'effects', 'run', 'confirm', 'confirm_summary'];
43
+
44
+ // The checks of PluginRuntime#definition, and the id the candidate was opened for.
45
+ function checkDefinition(definition, id) {
46
+ if (!definition || typeof definition !== 'object' || !idPattern.test(definition.id) || definition.id.length > 24 || typeof definition.version !== 'string' || !definition.version || typeof definition.activate !== 'function') throw new Error('Invalid plugin manifest: requires id, version and activate.');
47
+ if (definition.id !== id) throw new Error(`The module's plugin id ${definition.id} is not the candidate's id ${id}.`);
48
+ for (const key of ['requires', 'provides']) {
49
+ const list = definition[key] ?? [];
50
+ if (!Array.isArray(list) || list.length > 32 || list.some(name => typeof name !== 'string' || !name || name.length > 128) || new Set(list).size !== list.length) throw new Error('Invalid plugin requires/provides.');
51
+ }
52
+ if (definition.tool_loading !== undefined && !['eager', 'deferred'].includes(definition.tool_loading)) throw new Error('tool_loading must be eager or deferred.');
53
+ if (definition.hook_timeout_ms !== undefined && (!Number.isInteger(definition.hook_timeout_ms) || definition.hook_timeout_ms < 10 || definition.hook_timeout_ms > 5000)) throw new Error('hook_timeout_ms requires 10–5000 ms.');
54
+ return { requires: [...(definition.requires ?? [])], provides: [...(definition.provides ?? [])], cases: validateCases(definition.cases) };
55
+ }
56
+
57
+ // What the candidate printed to stderr is part of the report: let it drain (briefly) before leaving.
58
+ async function leave(code) {
59
+ const until = performance.now() + 500;
60
+ while (process.stderr.writableLength > 0 && performance.now() < until) await new Promise(resolve => setTimeout(resolve, 5));
61
+ process.exit(code);
62
+ }
63
+
64
+ const lines = createInterface({ input: process.stdin, crlfDelay: Infinity })[Symbol.asyncIterator]();
65
+ async function next() {
66
+ const { value, done } = await lines.next();
67
+ if (done) process.exit(0); // the parent is gone
68
+ return JSON.parse(value);
69
+ }
70
+
71
+ async function main() {
72
+ const init = await next();
73
+ if (init?.type !== 'init') throw new Error('Expected init.');
74
+ const started = performance.now();
75
+ const remaining = () => Math.max(0, init.deadline_ms - (performance.now() - started));
76
+ const id = init.id, config = init.config ?? {};
77
+ // The house's own services, stood in: the plain data the parent passed, plus a function for each member
78
+ // that is a function in the house. Such a function exists (a plugin may look at it) and throws when it is
79
+ // called: this process has no house to answer it. What activation needed and could not have is told to
80
+ // the parent, which then says the candidate was not judged rather than that it failed.
81
+ const unavailable = new Set();
82
+ const notHere = what => Object.assign(new Error(`${what} is not available in the pre-flight: it runs in a separate process without the house. The house verifies the candidate when it is accepted.`), { code: 'PLUGIN_PREFLIGHT_UNAVAILABLE' });
83
+ const services = {};
84
+ for (const [name, value] of Object.entries(init.services ?? {})) {
85
+ const members = Array.isArray(init.service_functions?.[name]) ? init.service_functions[name].filter(key => typeof key === 'string') : [];
86
+ if (!members.length || value === null || typeof value !== 'object' || Array.isArray(value)) { services[name] = value; continue; }
87
+ const standIn = { ...value };
88
+ for (const key of members) Object.defineProperty(standIn, key, { enumerable: true, value: () => { if (staging) unavailable.add(`${name}.${key}()`); throw notHere(`${name}.${key}`); } });
89
+ services[name] = Object.freeze(standIn);
90
+ }
91
+
92
+ let module, definition, manifest;
93
+ try {
94
+ module = await import(pathToFileURL(process.argv[2]).href);
95
+ definition = module.default;
96
+ manifest = checkDefinition(definition, id);
97
+ } catch (error) {
98
+ send({ type: 'import_failed', error: errorText(error) });
99
+ return leave(0);
100
+ }
101
+ const missing = manifest.requires.filter(name => !Object.hasOwn(services, name));
102
+ send({ type: 'imported', missing_services: missing });
103
+
104
+ // The recording context: the surface of PluginRuntime#stage.
105
+ const controller = new AbortController();
106
+ const operations = new Map(), provided = new Set(), disposers = [];
107
+ let staging = true;
108
+ const declaring = () => { if (!staging) throw new Error('Capabilities are only declared during activate.'); };
109
+ const register = (spec, human = false) => {
110
+ declaring();
111
+ for (const key of Object.keys(spec)) if (!OPERATION_KEYS.includes(key)) throw new Error(`Contract reserved to the host: ${key}`);
112
+ if (Object.hasOwn(spec, 'confirm') && spec.confirm !== 'always') throw new Error("A plugin's confirm only admits 'always'.");
113
+ if (Object.hasOwn(spec, 'confirm_summary') && typeof spec.confirm_summary !== 'function') throw new Error("A plugin's confirm_summary must be a function.");
114
+ if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(spec.name) || typeof spec.run !== 'function') throw new Error('Invalid plugin operation name/handler.');
115
+ const name = `plugin__${id}__${spec.name}`;
116
+ if (name.length > 64 || operations.has(name)) throw new Error(`Duplicate operation or name too long: ${name}`);
117
+ const handler = spec.run;
118
+ const operation = new Operation({ name, description: spec.description, input_schema: spec.input_schema, output_schema: spec.output_schema, effects: spec.effects || toolEffects('__unknown_plugin'), audience: human ? 'human' : 'any', surfaces: human ? ['cli', 'tui'] : ['cli', 'tui', 'agent'], permission: name, ...(spec.confirm ? { confirm: spec.confirm } : {}), run: (input, signal) => handler(immutable(jsonCopy(input)), Object.freeze({ signal })) });
119
+ operations.set(name, operation);
120
+ return name;
121
+ };
122
+ const context = Object.freeze({
123
+ signal: controller.signal,
124
+ config: immutable(jsonCopy(config)),
125
+ operation: spec => register(spec), command: spec => register(spec, true),
126
+ service: name => {
127
+ if (!manifest.requires.includes(name)) throw new Error(`Service not declared/available: ${name}`);
128
+ // Declared, and not one of the house's own: another plugin provides it, and none is loaded here.
129
+ if (!Object.hasOwn(services, name)) { if (staging) unavailable.add(name); throw notHere(`Service ${name}`); }
130
+ return services[name];
131
+ },
132
+ provide: name => { declaring(); if (!manifest.provides.includes(name) || provided.has(name)) throw new Error(`Service not declared/duplicate: ${name}`); provided.add(name); },
133
+ defer: disposer => { if (typeof disposer !== 'function') throw new Error('Cleanup requires a function.'); disposers.push(disposer); },
134
+ on: (event, listener) => { if (!HOOK_EVENTS.includes(event) || typeof listener !== 'function') throw new Error('Invalid plugin hook.'); },
135
+ guard: check => { declaring(); if (typeof check !== 'function') throw new Error('A guard requires a function.'); },
136
+ notify: async () => ({ status: 'unavailable' }),
137
+ });
138
+ const listed = () => [...operations.values()].slice(0, MAX_OPERATIONS).map(({ contract }) => ({ name: contract.name, description: text(contract.description), input_schema: contract.input_schema, output_schema: contract.output_schema, effects: contract.effects, audience: contract.audience }));
139
+
140
+ try {
141
+ if (definition.config_schema) new Operation({ name: `${id}_config`, description: 'Plugin configuration', input_schema: definition.config_schema, run: () => '' }).validate(config);
142
+ await definition.activate(context, context.config);
143
+ for (const name of manifest.provides) if (!provided.has(name)) throw new Error(`The plugin did not publish the declared service: ${name}`);
144
+ validateCases(manifest.cases, [...operations.keys()].map(name => name.slice(`plugin__${id}__`.length)));
145
+ } catch (error) {
146
+ send({ type: 'activate_failed', error: errorText(error), operations: listed(), ...(unavailable.size ? { unavailable_services: [...unavailable].slice(0, 32) } : {}) });
147
+ return leave(0);
148
+ }
149
+ staging = false;
150
+ send({ type: 'activated', operations: listed(), cases: manifest.cases });
151
+
152
+ // A handler run as the house runs it: input validated, a frozen copy in, output validated.
153
+ const invoke = async (name, input, budget) => {
154
+ const operation = operations.get(published(id, String(name)));
155
+ if (!operation) throw new Error(`Unknown operation: ${name}`);
156
+ operation.validate(input);
157
+ const output = await operation.run(input, AbortSignal.any([controller.signal, AbortSignal.timeout(Math.max(1, Math.floor(Number(budget) || 1)))]));
158
+ operation.validateOutput(output);
159
+ return output;
160
+ };
161
+
162
+ const checks = [];
163
+ const suite = module.checks ?? definition.checks;
164
+ if (suite !== undefined) {
165
+ if (!suite || typeof suite !== 'object' || Array.isArray(suite)) checks.push({ name: 'checks', ok: false, error: 'The checks export must be an object of functions.' });
166
+ else for (const [name, check] of Object.entries(suite).slice(0, MAX_CHECKS)) {
167
+ if (typeof check !== 'function') { checks.push({ name: text(name), ok: false, error: 'Not a function.' }); continue; }
168
+ try { await check((operation, input) => invoke(operation, input, remaining())); checks.push({ name: text(name), ok: true }); }
169
+ catch (error) { checks.push({ name: text(name), ok: false, error: errorText(error) }); }
170
+ }
171
+ }
172
+ send({ type: 'checks', checks });
173
+ send({ type: 'ready' });
174
+
175
+ let outputLeft = OUTPUT_TOTAL; // the parent's budget for asked-for outputs, kept here too so stdout stays bounded
176
+ for (;;) {
177
+ const request = await next();
178
+ if (request?.type === 'replay') {
179
+ try {
180
+ const output = await invoke(request.operation, request.input, request.remaining_ms);
181
+ let rendered;
182
+ try { rendered = typeof output === 'string' ? output : JSON.stringify(output) ?? '(no output)'; }
183
+ catch { throw new Error('The output is not JSON-serializable.'); }
184
+ let asked = {};
185
+ if (request.want_output === true) {
186
+ const bytes = Buffer.byteLength(JSON.stringify(rendered));
187
+ asked = bytes > OUTPUT_BYTES ? { output_omitted: 'too_large' } : bytes > outputLeft ? { output_omitted: 'budget' } : (outputLeft -= bytes, { output: rendered });
188
+ }
189
+ send({ type: 'replayed', seq: request.seq, outcome: 'returned', output_sha256: digest(rendered), ...asked });
190
+ } catch (error) {
191
+ send({ type: 'replayed', seq: request.seq, outcome: 'threw', error: errorText(error), ...(typeof error?.code === 'string' ? { code: error.code.slice(0, 128) } : {}) });
192
+ }
193
+ } else if (request?.type === 'done') {
194
+ // Retire as the runtime does: abort the scope, then run the cleanups in reverse.
195
+ controller.abort(new Error('Plugin retired.'));
196
+ const errors = [];
197
+ for (const disposer of disposers.reverse()) try { await disposer(); } catch (error) { errors.push(errorText(error)); }
198
+ send({ type: 'closed', cleanup: errors.length ? { ok: false, error: text(errors.join('; ')) } : { ok: true } });
199
+ return leave(0);
200
+ } else throw new Error('Unexpected request.');
201
+ }
202
+ }
203
+
204
+ main().catch(error => { toStderr(`plugin-candidate-child: ${errorText(error)}\n`); process.exit(70); });