@milpa/comal 0.1.7 → 0.2.1

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 (134) hide show
  1. package/README.md +19 -1
  2. package/backend/contracts/remote-commands-v1.json +12 -0
  3. package/governance/chrome-task-v10.json +323 -0
  4. package/governance/chrome-task-v11.json +355 -0
  5. package/governance/chrome-task-v12.json +387 -0
  6. package/governance/chrome-task-v13.json +419 -0
  7. package/governance/chrome-task-v3.json +99 -0
  8. package/governance/chrome-task-v4.json +131 -0
  9. package/governance/chrome-task-v5.json +163 -0
  10. package/governance/chrome-task-v6.json +195 -0
  11. package/governance/chrome-task-v7.json +227 -0
  12. package/governance/chrome-task-v8.json +259 -0
  13. package/governance/chrome-task-v9.json +291 -0
  14. package/governance/source-task-v1.json +1 -0
  15. package/governance/source-task-v2.json +1 -0
  16. package/package.json +14 -1
  17. package/src/agent-recovery-proof.mjs +12 -1
  18. package/src/agent.mjs +94 -30
  19. package/src/audit.mjs +21 -6
  20. package/src/auto-guard.mjs +48 -1
  21. package/src/chrome-candidate-recovery-proof.mjs +47 -0
  22. package/src/chrome-recovery-proof.mjs +6 -2
  23. package/src/chrome-source-syntax.mjs +93 -0
  24. package/src/cli.mjs +158 -54
  25. package/src/codex-provider.mjs +3 -2
  26. package/src/connection-settings.mjs +204 -0
  27. package/src/context-pressure.mjs +2 -1
  28. package/src/durable-agent-runs.mjs +5 -11
  29. package/src/event-channels.mjs +8 -0
  30. package/src/file-recovery-proof.mjs +11 -3
  31. package/src/file-recovery.mjs +24 -0
  32. package/src/gemini-provider.mjs +6 -1
  33. package/src/git-agent-revision.mjs +12 -0
  34. package/src/git-agent-workspaces.mjs +143 -0
  35. package/src/git-integration-lineage.mjs +20 -0
  36. package/src/git-integration-operations.mjs +200 -0
  37. package/src/git-integration-recovery-proof.mjs +70 -0
  38. package/src/git-page-cli-bridge.mjs +61 -0
  39. package/src/git-page-evidence.mjs +30 -0
  40. package/src/git-page-identity.mjs +10 -0
  41. package/src/git-page-proof.mjs +96 -0
  42. package/src/git-page-settlement.mjs +21 -0
  43. package/src/git-page-verification.mjs +134 -0
  44. package/src/git-plumbing.mjs +212 -0
  45. package/src/git-process-worker.mjs +50 -0
  46. package/src/git-source.mjs +110 -0
  47. package/src/git-workspaces.mjs +519 -0
  48. package/src/goals.mjs +13 -7
  49. package/src/house.mjs +246 -20
  50. package/src/i18n/cli-es.mjs +4 -0
  51. package/src/i18n/es.mjs +35 -0
  52. package/src/judges.mjs +27 -11
  53. package/src/mcp-runtime.mjs +15 -3
  54. package/src/model-controls.mjs +11 -1
  55. package/src/model-deadline.mjs +4 -3
  56. package/src/native-refusal.mjs +38 -0
  57. package/src/onboarding.mjs +88 -24
  58. package/src/openai-provider.mjs +14 -7
  59. package/src/operations.mjs +9 -2
  60. package/src/output-recovery.mjs +48 -0
  61. package/src/peer-tools.mjs +18 -8
  62. package/src/permission-controls.mjs +232 -0
  63. package/src/permission-rules.mjs +312 -0
  64. package/src/permissions.mjs +113 -30
  65. package/src/plugin-catalog.mjs +84 -0
  66. package/src/plugin-config.mjs +48 -6
  67. package/src/plugin-controls.mjs +332 -0
  68. package/src/plugin-notices.mjs +88 -0
  69. package/src/plugin-trust.mjs +104 -0
  70. package/src/plugins/chrome-lab/bridge.mjs +38 -12
  71. package/src/plugins/chrome-lab/client.mjs +103 -25
  72. package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
  73. package/src/plugins/chrome-lab/index.mjs +116 -26
  74. package/src/plugins/chrome-lab/project.mjs +30 -2
  75. package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
  76. package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
  77. package/src/plugins/open-relay/actions.mjs +205 -0
  78. package/src/plugins/open-relay/bound.mjs +47 -0
  79. package/src/plugins/open-relay/client.mjs +158 -0
  80. package/src/plugins/open-relay/dossier.mjs +119 -0
  81. package/src/plugins/open-relay/index.mjs +153 -0
  82. package/src/plugins/open-relay/lookup.mjs +45 -0
  83. package/src/plugins/open-relay/notices.mjs +79 -0
  84. package/src/plugins/open-relay/watch.mjs +127 -0
  85. package/src/plugins.mjs +72 -29
  86. package/src/private-fs.mjs +46 -0
  87. package/src/process-lock.mjs +24 -13
  88. package/src/provider-auth.mjs +14 -3
  89. package/src/provider.mjs +11 -2
  90. package/src/read-only-check.mjs +7 -3
  91. package/src/scope-ceiling.mjs +56 -0
  92. package/src/scoped-policy.mjs +39 -0
  93. package/src/secret-login.mjs +68 -0
  94. package/src/session-bus.mjs +41 -7
  95. package/src/session-projections-candidate.mjs +119 -0
  96. package/src/session-projections.mjs +119 -0
  97. package/src/session-read-cache.mjs +23 -3
  98. package/src/sessions.mjs +35 -11
  99. package/src/source-editor-role.mjs +13 -0
  100. package/src/source-ownership.mjs +76 -0
  101. package/src/source-proposal-proof.mjs +55 -0
  102. package/src/source-proposals.mjs +118 -0
  103. package/src/source-requests.mjs +148 -0
  104. package/src/source-task-proof.mjs +24 -0
  105. package/src/source-task-revision-proof.mjs +44 -0
  106. package/src/subagents.mjs +187 -87
  107. package/src/tool-packs.mjs +5 -5
  108. package/src/tools.mjs +82 -15
  109. package/src/trusted-context.mjs +5 -1
  110. package/src/tui/connection-transition.mjs +1 -1
  111. package/src/tui/controller.mjs +8 -4
  112. package/src/tui/discovery.mjs +24 -12
  113. package/src/tui/inbound-blocks.mjs +55 -0
  114. package/src/tui/present.mjs +504 -0
  115. package/src/tui/screen.mjs +49 -13
  116. package/src/windows-private.ps1 +63 -0
  117. package/src/work-assertions.mjs +6 -2
  118. package/src/work-plan.mjs +34 -8
  119. package/src/work-state.mjs +15 -9
  120. package/src/workflows/chrome-page-cases.mjs +156 -0
  121. package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
  122. package/src/workflows/chrome-page-diagnostics.mjs +113 -0
  123. package/src/workflows/chrome-page-law.mjs +48 -0
  124. package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
  125. package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
  126. package/src/workflows/chrome-page-task.mjs +429 -0
  127. package/src/workflows/chrome-page-verifier.mjs +123 -0
  128. package/src/workflows/chrome-source.mjs +73 -0
  129. package/src/workflows/chrome-supplied-inputs.mjs +43 -0
  130. package/src/workflows/chrome-task-adoption.mjs +122 -0
  131. package/src/workflows/chrome-task.mjs +379 -74
  132. package/src/workflows/source-task-v1.mjs +124 -0
  133. package/src/workflows/source-task-v2.mjs +227 -0
  134. package/src/workflows/source-task.mjs +13 -0
@@ -1,16 +1,19 @@
1
1
  // Work controls write only the session ledger; nested effects still use the house judge.
2
2
  const READ_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'McpStatus', 'HouseContext', 'HouseSnapshot', 'OperationCatalog', 'ReadTable', 'PluginStatus', 'WorkSnapshot', 'AuditStatus', 'EvidenceRead', 'WorkPlanSet', 'WorkCheckRepair', 'WorkTaskUpdate', 'WorkStepRun', 'WorkDraft', 'WorkBoard']);
3
- const EDIT_TOOLS = new Set(['Write', 'Edit', 'McpConfigure']);
3
+ const EDIT_TOOLS = new Set(['Write', 'Edit', 'McpConfigure','GitIntegrationPrepare','GitIntegrationApply']);
4
4
  import { inspectAutoAction, intentCoversAction } from './auto-guard.mjs';
5
5
  import { randomUUID } from 'node:crypto';
6
- import { readOnlyBashCheck, chromeLabObservation } from './read-only-check.mjs';
6
+ import { readOnlyBashCheck, chromeLabObservation, openRelayObservation } from './read-only-check.mjs';
7
+ import { describeRule, suggestRule } from './permission-rules.mjs';
7
8
  READ_TOOLS.add('ToolPack');
8
9
  for (const name of ['ArtifactAccount','ArtifactCapabilities','ArtifactList','ArtifactInspect']) READ_TOOLS.add(name);
9
- for (const name of ['ListDirectory', 'FileInfo']) READ_TOOLS.add(name);
10
+ for (const name of ['ListDirectory', 'FileInfo', 'SourceRead', 'GitInspect','GitIntegrationStatus','GitIntegrationEvidence']) READ_TOOLS.add(name);
10
11
  for (const name of ['ListAgents','SendMessage','ProviderCapabilities','ProviderSearch','AnalyzeMedia']) READ_TOOLS.add(name);
11
12
 
12
13
  export const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'auto'];
13
- export function nextPermissionMode(mode) { return PERMISSION_MODES[(PERMISSION_MODES.indexOf(mode) + 1) % PERMISSION_MODES.length]; }
14
+ export const BYPASS_PERMISSION_MODE = 'bypassPermissions';
15
+ export const BYPASS_LOCKED_MESSAGE = 'Permission bypass is fixed for this process. Restart Comal without --dangerously-skip-permissions to change modes.';
16
+ export function nextPermissionMode(mode) { return mode === BYPASS_PERMISSION_MODE ? mode : PERMISSION_MODES[(PERMISSION_MODES.indexOf(mode) + 1) % PERMISSION_MODES.length]; }
14
17
 
15
18
  // A UI callback may be shared by resident and subagent policies. One visible
16
19
  // question at a time prevents another invocation from replacing its authority.
@@ -28,31 +31,98 @@ function waitWithAbort(promise, signal) {
28
31
  });
29
32
  }
30
33
  function permissionStamp(policy) {
34
+ return JSON.stringify([policy.mode,[...policy.allowed].sort(),[...policy.denied].sort(),policy.context?.intentions,policy.rules?.revision ?? null]);
35
+ }
36
+ // Lo que House observa como autoridad; si sólo cambian las reglas guardadas, la propia
37
+ // política vuelve a decidir (p. ej. otra pregunta acaba de guardar «sí, siempre»).
38
+ function houseStamp(policy) {
31
39
  return JSON.stringify([policy.mode,[...policy.allowed].sort(),[...policy.denied].sort(),policy.context?.intentions]);
32
40
  }
41
+ // La tarjeta puede responder true/false o { allowed, remember: 'project'|'user' }.
42
+ function normalizeAnswer(value, offer) {
43
+ if (value && typeof value === 'object') return { allowed: Boolean(value.allowed), remember: offer && value.allowed && ['project', 'user'].includes(value.remember) ? value.remember : null };
44
+ if (offer && ['project', 'user'].includes(value)) return { allowed: true, remember: value };
45
+ return { allowed: Boolean(value), remember: null };
46
+ }
47
+ const CONFIRM_ALWAYS = 'confirm:"always"';
33
48
 
34
49
  export class PermissionPolicy {
35
- constructor({ mode = 'default', allowed = [], denied = [], confirm, classifier, cwd, onDecision = () => {} } = {}) {
36
- if (!PERMISSION_MODES.includes(mode)) throw new Error('Invalid permission mode. Use default, plan, acceptEdits or auto.');
37
- this.mode = mode; this.allowed = new Set(allowed); this.denied = new Set(denied); this.confirm = confirm;
38
- this.classifier = classifier; this.cwd = cwd; this.onDecision = onDecision; this.context = { intentions: [], actions: [] };
50
+ #mode;
51
+ // rules: almacén de reglas guardadas (permission-rules.mjs) con decide(name, input) → {list, rule, scope}.
52
+ // contractOf(name): contrato vivo de la operación (house.registry.get(name)?.contract); de ahí sale
53
+ // el techo duro confirm:'always', que sólo el host lee y el modelo no puede cambiar.
54
+ constructor({ mode = 'default', dangerouslySkipPermissions = false, allowed = [], denied = [], confirm, classifier, cwd, rules, contractOf, onDecision = () => {} } = {}) {
55
+ 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.');
56
+ Object.defineProperty(this, 'dangerouslySkipPermissions', { value: dangerouslySkipPermissions === true, enumerable: true });
57
+ this.#mode = this.dangerouslySkipPermissions ? BYPASS_PERMISSION_MODE : mode;
58
+ this.allowed = new Set(allowed); this.denied = new Set(denied); this.confirm = confirm;
59
+ this.classifier = classifier; this.cwd = cwd; this.rules = rules; this.contractOf = contractOf; this.onDecision = onDecision; this.context = { intentions: [], actions: [] };
60
+ }
61
+ get mode() { return this.#mode; }
62
+ set mode(mode) {
63
+ if (this.dangerouslySkipPermissions) throw new Error(BYPASS_LOCKED_MESSAGE);
64
+ if (!PERMISSION_MODES.includes(mode)) throw new Error('Bypass requires --dangerously-skip-permissions at startup; it cannot be enabled by changing modes.');
65
+ this.#mode = mode;
39
66
  }
40
67
  setContext(context) { this.context = context; }
41
- async authorize(name, input, { signal, onWaiting } = {}) {
68
+ /** ¿Declara el contrato vivo de la operación confirm:'always'? */
69
+ confirmAlways(name) { try { return this.contractOf?.(name)?.confirm === 'always'; } catch { return false; } }
70
+ /**
71
+ * Precedencia (la primera que decide gana):
72
+ * 1. --disallowed-tools y reglas deny;
73
+ * 2. bypass de arranque (--dangerously-skip-permissions);
74
+ * 3. techo confirm:'always' (plan → deniega; sin humano → deniega; si no, pregunta siempre);
75
+ * 4. reglas ask (preguntan incluso en auto);
76
+ * 5. plan (sólo lecturas), lecturas nativas, allow / --allowed-tools;
77
+ * 6. modos: auto (guardia + clasificador), acceptEdits, default (pregunta).
78
+ */
79
+ async authorize(name, input, options = {}) {
80
+ for (let attempt = 0; attempt < 3; attempt++) {
81
+ const result = await this.#authorize(name, input, options);
82
+ if (!result.stale) return result;
83
+ }
84
+ return { allowed: false, reason: 'Las reglas de permiso cambiaron mientras la pregunta esperaba; propón la acción de nuevo.' };
85
+ }
86
+ async #authorize(name, input, { signal, onWaiting, invocation, hostFacts } = {}) {
42
87
  signal?.throwIfAborted();
43
88
  if (this.denied.has(name)) return { allowed: false, reason: 'Explicit denial rule' };
44
- const knownRead = READ_TOOLS.has(name) || this.allowed.has(name) && chromeLabObservation(name);
89
+ // Saved human rules: deny > ask > allow. Ask forces a question even in auto; the startup bypass skips it (deny still applies).
90
+ const rule = this.rules?.decide(name, input), ruled = rule ? `Permission ${describeRule(rule)}` : null, cite = rule ? { rule: rule.rule } : {};
91
+ if (rule?.list === 'deny') return { allowed: false, reason: ruled, source: 'permission_rule', ...cite };
92
+ if (this.mode === BYPASS_PERMISSION_MODE) return { allowed: true, reason: 'Explicit startup --dangerously-skip-permissions; permission prompts and classifier skipped', source: 'startup_bypass' };
93
+ if (this.confirmAlways(name)) {
94
+ if (this.mode === 'plan') return { allowed: false, reason: `Plan mode: la operación declara ${CONFIRM_ALWAYS} y plan no la ejecuta`, source: 'confirm_always' };
95
+ if (!this.confirm) return { allowed: false, reason: `La operación declara ${CONFIRM_ALWAYS} y no hay un humano que confirme`, source: 'confirm_always' };
96
+ const answer = await this.#question(name, input, signal, onWaiting, null); if (answer.stale) return answer;
97
+ return { allowed: answer.allowed, reason: `Confirmación obligatoria (${CONFIRM_ALWAYS}): ${answer.allowed ? 'User confirmation' : 'No authorization for this tool'}`, source: 'confirm_always' };
98
+ }
99
+ 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 }; }
100
+ const granted = this.allowed.has(name) || rule?.list === 'allow';
101
+ const knownRead = READ_TOOLS.has(name) || granted && (chromeLabObservation(name) || openRelayObservation(name));
45
102
  if (name === 'Agent' || name === 'AgentBatch') return { allowed: true, reason: 'Delegation: every child action retains the session permissions' };
46
103
  if (this.mode === 'plan' && !knownRead) return { allowed: false, reason: 'Plan mode: read-only' };
47
104
  if (name === 'Bash' && readOnlyBashCheck(input.command)) return { allowed: true, reason: 'Native read-only check with fixed argv; no classifier required.' };
48
- if (this.mode === 'auto' && !knownRead) return this.authorizeAuto(name, input, signal, onWaiting);
49
- if (knownRead || this.allowed.has(name) || (this.mode === 'acceptEdits' && EDIT_TOOLS.has(name))) return { allowed: true, reason: 'Session policy' };
50
- const allowed = await this.ask(name, input, signal, onWaiting);
51
- return { allowed, reason: allowed ? 'User confirmation' : 'No authorization for this tool' };
105
+ if (this.mode === 'auto' && !knownRead) return this.authorizeAuto(name, input, signal, onWaiting, invocation, hostFacts, rule);
106
+ if (knownRead || granted || (this.mode === 'acceptEdits' && EDIT_TOOLS.has(name))) return { allowed: true, reason: !this.allowed.has(name) && rule?.list === 'allow' && !READ_TOOLS.has(name) ? ruled : 'Session policy', ...(ruled && rule.list === 'allow' && !this.allowed.has(name) ? { source: 'permission_rule', ...cite } : {}) };
107
+ return this.#askWithOffer(name, input, signal, onWaiting);
108
+ }
109
+ // Pregunta normal: ofrece «sí, siempre» con la regla más específica razonable.
110
+ async #askWithOffer(name, input, signal, onWaiting, reason) {
111
+ const offer = this.rememberOffer(name, input), answer = await this.#question(name, input, signal, onWaiting, offer);
112
+ if (answer.stale) return answer;
113
+ const base = reason ?? (answer.allowed ? 'User confirmation' : 'No authorization for this tool');
114
+ 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 } : {}) };
115
+ }
116
+ /** Regla propuesta para «sí, siempre» o null (sin almacén, confirm:'always' o sin regla segura). */
117
+ rememberOffer(name, input) {
118
+ if (!this.rules?.add || this.confirmAlways(name)) return null;
119
+ const rule = suggestRule(name, input, { project: this.rules.project || this.cwd || process.cwd() });
120
+ return rule ? { rule, project: this.rules.scope ? this.rules.project : null } : null;
52
121
  }
53
- async ask(name, input, signal, onWaiting) {
54
- if (!this.confirm) return false;
55
- const confirm=this.confirm, stamp=permissionStamp(this), previous=confirmationQueues.get(confirm) || Promise.resolve();
122
+ async ask(name, input, signal, onWaiting) { return (await this.#question(name, input, signal, onWaiting, null)).allowed; }
123
+ async #question(name, input, signal, onWaiting, offer) {
124
+ if (!this.confirm) return { allowed: false };
125
+ const confirm=this.confirm, stamp=permissionStamp(this), visible=houseStamp(this), previous=confirmationQueues.get(confirm) || Promise.resolve();
56
126
  let release;
57
127
  const slot=new Promise(resolve => {release=resolve;});
58
128
  const tail=previous.catch(() => {}).then(() => slot);
@@ -63,37 +133,50 @@ export class PermissionPolicy {
63
133
  signal?.throwIfAborted();
64
134
  // Do not show a stale question. House observes the changed authority and
65
135
  // calls authorize again, so newly denied/plan actions cannot reach a UI.
66
- if(this.confirm!==confirm || stamp!==permissionStamp(this))return false;
136
+ // If only saved rules changed, this policy decides again by itself.
137
+ if(this.confirm!==confirm || stamp!==permissionStamp(this))return this.confirm===confirm && visible===houseStamp(this) ? { allowed: false, stale: true } : { allowed: false };
67
138
  await onWaiting?.({type:'house_waiting',question_id:questionId});
68
139
  displayed=true;signal?.throwIfAborted();
69
- allowed=Boolean(await waitWithAbort(confirm(name,input,{signal,questionId}),signal));
140
+ const answer=normalizeAnswer(await waitWithAbort(confirm(name,input,{signal,questionId,...(offer?{remember:offer}:{})}),signal),offer);
70
141
  signal?.throwIfAborted();
71
- return stamp===permissionStamp(this) && this.confirm===confirm ? allowed : false;
142
+ allowed=answer.allowed && this.confirm===confirm && (stamp===permissionStamp(this) || visible===houseStamp(this) && this.rules?.decide(name,input)?.list!=='deny');
143
+ if(!allowed || !answer.remember)return { allowed };
144
+ // Se guarda antes de liberar el turno, para que la siguiente pregunta ya la vea.
145
+ const scope=answer.remember==='user' ? 'user' : this.rules.scope;
146
+ if(!scope)return { allowed, saveError: 'this session has no project' };
147
+ try { return { allowed, saved: await this.rules.add({ list: 'allow', rule: offer.rule, scope }) }; }
148
+ catch(error) { return { allowed, saveError: error.message }; }
72
149
  } finally {
73
- try { if(displayed)await onWaiting?.({type:'house_answered',question_id:questionId,allowed:allowed && !signal?.aborted && stamp===permissionStamp(this)}); }
150
+ try { if(displayed)await onWaiting?.({type:'house_answered',question_id:questionId,allowed:allowed && !signal?.aborted}); }
74
151
  finally {release();if(confirmationQueues.get(confirm)===tail)tail.then(()=>{if(confirmationQueues.get(confirm)===tail)confirmationQueues.delete(confirm);});}
75
152
  }
76
153
  }
77
- async authorizeAuto(name, input, signal, onWaiting) {
154
+ async authorizeAuto(name, input, signal, onWaiting, invocation, hostFacts, rule = this.rules?.decide(name, input)) {
78
155
  const inspection = inspectAutoAction(name, input, this.cwd || process.cwd());
79
156
  const data = { workspace: this.cwd, human_requests: this.context.intentions, previous_tool_calls: this.context.actions, proposed_action: { name, input } };
157
+ if(hostFacts)data.host_verified_facts=hostFacts;
80
158
  let verdict;
81
159
  if (inspection.hard) verdict = { decision: 'deny', risk: 'destructive', reason: inspection.hard, intent_quote: '', source: 'guard' };
82
160
  else if (!this.classifier || !data.human_requests.length || Buffer.byteLength(JSON.stringify(data)) > 64000) verdict = { decision: 'ask', risk: 'unknown', reason: 'No classifier or insufficient intent context for automatic approval.', intent_quote: '', source: 'guard' };
83
161
  else try {
84
- verdict = { ...await this.classifier.classify(data, signal), source: 'classifier' };
162
+ const classified = await this.classifier.classify(data, signal, invocation || {});
163
+ // Custom classifiers also supply data, not journal metadata. Do not spread it.
164
+ verdict = { decision: classified.decision, risk: classified.risk, reason: classified.reason, intent_quote: classified.intent_quote, ...(classified.target_quote !== undefined ? { target_quote: classified.target_quote } : {}), ...(classified.retry_attempts === 1 ? { retry_attempts: 1 } : {}), source: 'classifier' };
165
+ if (verdict.decision === 'allow' && (inspection.destructive || ['destructive','external'].includes(verdict.risk)) && verdict.target_quote && !data.human_requests.at(-1)?.includes(verdict.target_quote)) verdict = { ...verdict, decision: 'ask', reason: 'The target citation is not a literal span of the latest human request.' };
85
166
  if (verdict.decision === 'allow' && (inspection.opaque || verdict.risk === 'unknown')) verdict = { ...verdict, decision: 'ask', reason: 'The action contains expansion, opaque execution or unknown effects; review is required.' };
86
- if (verdict.decision === 'allow' && (inspection.destructive || verdict.risk === 'destructive') && !intentCoversAction(verdict, data.proposed_action, inspection, data.human_requests)) verdict = { ...verdict, decision: 'deny', reason: 'The latest intent does not explicitly authorize the destructive action and its target.' };
87
- if (verdict.decision === 'allow' && verdict.risk === 'external' && (!verdict.intent_quote || !data.human_requests.at(-1)?.includes(verdict.intent_quote))) verdict = { ...verdict, decision: 'ask', reason: 'Explicit authorization for the external effect is missing.' };
167
+ if (verdict.decision === 'allow' && (inspection.destructive || verdict.risk === 'destructive') && !intentCoversAction(verdict, data.proposed_action, inspection, data.human_requests, hostFacts)) verdict = { ...verdict, decision: 'deny', reason: 'The latest intent does not explicitly authorize the destructive action and its target.' };
168
+ if (verdict.decision === 'allow' && verdict.risk === 'external' && (!verdict.intent_quote || !data.human_requests.at(-1)?.includes(verdict.intent_quote) || verdict.target_quote && !data.human_requests.at(-1)?.includes(verdict.target_quote))) verdict = { ...verdict, decision: 'ask', reason: 'Explicit authorization for the external effect is missing.' };
88
169
  } catch (error) {
89
170
  if (signal?.aborted || error.code === 'GOAL_BUDGET') throw error;
90
171
  verdict = { decision: 'ask', risk: 'unknown', reason: `Auto verifier unavailable: ${error.message}`, intent_quote: '', source: 'classifier-error' };
91
172
  }
92
173
  signal?.throwIfAborted();
93
- if (this.mode !== 'auto') return this.authorize(name, input, { signal, onWaiting });
94
- await this.onDecision({ type: 'auto_decision', name, input, ...verdict });
95
- let allowed = verdict.decision === 'allow';
96
- if (verdict.decision === 'ask') allowed = await this.ask(name, input, signal, onWaiting);
97
- return { allowed, reason: verdict.reason, decision: verdict.decision, risk: verdict.risk, source: verdict.source };
174
+ if (this.mode !== 'auto') return this.authorize(name, input, { signal, onWaiting, invocation, hostFacts });
175
+ await this.onDecision({ ...verdict, ...(invocation || {}), type: 'auto_decision', name, input });
176
+ // Una regla allow guardada responde por el humano cuando el veredicto sólo pide preguntar;
177
+ // nunca anula un deny del guardia o del clasificador.
178
+ if (verdict.decision === 'ask' && rule?.list === 'allow') return { allowed: true, reason: `Permission ${describeRule(rule)} (auto wanted to ask: ${verdict.reason})`, decision: verdict.decision, risk: verdict.risk, source: 'permission_rule', rule: rule.rule };
179
+ if (verdict.decision === 'ask') { const answer = await this.#askWithOffer(name, input, signal, onWaiting, verdict.reason); if (answer.stale) return answer; return { ...answer, reason: answer.reason, decision: verdict.decision, risk: verdict.risk, source: answer.source || verdict.source }; }
180
+ return { allowed: verdict.decision === 'allow', reason: verdict.reason, decision: verdict.decision, risk: verdict.risk, source: verdict.source };
98
181
  }
99
182
  }
@@ -0,0 +1,84 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { t, registerMessages } from './i18n/index.mjs';
3
+ import { Operation } from './operations.mjs';
4
+ import { createChromeLabPlugin, CHROME_LAB_TOOLS } from './plugins/chrome-lab/index.mjs';
5
+ import { chromeLabObservation, openRelayObservation } from './read-only-check.mjs';
6
+ import { createOpenRelayPlugin, OPEN_RELAY_TOOLS } from './plugins/open-relay/index.mjs';
7
+
8
+ // Plugins que viajan con Comal. La ruta es absoluta y se resuelve desde esta
9
+ // instalación, de modo que una entrada agregada funciona en cualquier proyecto.
10
+ const short = name => name.split('__').at(-1);
11
+ // La misma lista que la política ya trata como observación conocida (read-only-check).
12
+ const chromeObservations = CHROME_LAB_TOOLS.filter(chromeLabObservation).map(short);
13
+
14
+ const CHROME_DESCRIPTION = 'MV3 extension lab in a real Chrome on Windows over SSH + CDP: deploy, open, read the DOM, verify and download.';
15
+ const RELAY_DESCRIPTION = 'Operator of an open-relay swarm through the relayd admin socket: status, inbox, plans, jobs, notices, decision dossier and four actions (Reply, Ack, ApprovePlan, RejectPlan) with a confirmation card.';
16
+ registerMessages({
17
+ [CHROME_DESCRIPTION]: 'Laboratorio de extensiones MV3 en un Chrome real de Windows por SSH + CDP: desplegar, abrir, leer DOM, verificar y descargar.',
18
+ [RELAY_DESCRIPTION]: 'Operador de un enjambre open-relay por el socket admin de relayd: estado, buzón, planes, jobs, avisos, expediente de decisión y cuatro acciones (Reply, Ack, ApprovePlan, RejectPlan) con tarjeta de confirmación.',
19
+ '{id} needs {keys}. Example: /plugins add {id} {example}': '{id} necesita {keys}. Ejemplo: /plugins add {id} {example}',
20
+ 'Invalid setting «{token}». Use key=value.': 'Ajuste inválido «{token}». Usa clave=valor.',
21
+ '{key} must be an integer.': '{key} debe ser entero.',
22
+ });
23
+
24
+ const BUNDLED = Object.freeze({
25
+ 'chrome-lab': Object.freeze({
26
+ id: 'chrome-lab',
27
+ description: CHROME_DESCRIPTION,
28
+ module: fileURLToPath(new URL('./plugins/chrome-lab/index.mjs', import.meta.url)),
29
+ // Sin host ni ruta por omisión: dependen de la máquina Windows de cada quien.
30
+ defaults: Object.freeze({ cdpURL: 'http://127.0.0.1:9222', timeoutMs: 20000, maxDeployBytes: 10000000 }),
31
+ required: Object.freeze({ sshHost: 'HOST', remoteRoot: 'C:\\Users\\YOU\\AppData\\Local\\Comal\\chrome-lab' }),
32
+ schema: createChromeLabPlugin({ clientFactory: () => { throw new Error('Sólo esquema.'); } }).config_schema,
33
+ operations: Object.freeze(CHROME_LAB_TOOLS.map(short)),
34
+ observations: Object.freeze(chromeObservations),
35
+ // Ajustes del host (no del plugin): se guardan aparte en plugins.json.
36
+ host: Object.freeze({ workflow: Object.freeze(['auto', 'raw']) }),
37
+ }),
38
+ 'open-relay': Object.freeze({
39
+ id: 'open-relay',
40
+ description: RELAY_DESCRIPTION,
41
+ module: fileURLToPath(new URL('./plugins/open-relay/index.mjs', import.meta.url)),
42
+ defaults: Object.freeze({ notify: 'on' }),
43
+ required: Object.freeze({}),
44
+ schema: createOpenRelayPlugin().config_schema,
45
+ operations: Object.freeze(OPEN_RELAY_TOOLS.map(short)),
46
+ // Lecturas (incluido Dossier) son observaciones; Reply, Ack, ApprovePlan y RejectPlan, acciones.
47
+ observations: Object.freeze(OPEN_RELAY_TOOLS.filter(openRelayObservation).map(short)),
48
+ }),
49
+ });
50
+
51
+ export const bundledPlugins = () => Object.values(BUNDLED);
52
+ export const bundledPlugin = id => BUNDLED[id];
53
+ export const operationName = (id, name) => name.startsWith('plugin__') ? name : `plugin__${id}__${name}`;
54
+
55
+ /** Observaciones conocidas vs. acciones. Sólo los plugins incluidos declaran esta separación. */
56
+ export function pluginOperations(id) {
57
+ const item = BUNDLED[id]; if (!item) return null;
58
+ return { observations: item.observations.map(name => operationName(id, name)), actions: item.operations.filter(name => !item.observations.includes(name)).map(name => operationName(id, name)) };
59
+ }
60
+
61
+ /** key=value, separados por espacios. Enteros según el esquema; el resto como texto literal. */
62
+ export function parseAssignments(text = '', schema = {}) {
63
+ const result = {}, tokens = text.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
64
+ for (const token of tokens) {
65
+ const match = /^([A-Za-z][A-Za-z0-9_]{0,63})=(.*)$/s.exec(token);
66
+ if (!match) throw new Error(t('Invalid setting «{token}». Use key=value.', { token }));
67
+ let value = match[2].replace(/^"([\s\S]*)"$/, '$1');
68
+ if (schema.properties?.[match[1]]?.type === 'integer') { if (!/^-?\d+$/.test(value)) throw new Error(t('{key} must be an integer.', { key: match[1] })); value = Number(value); }
69
+ result[match[1]] = value;
70
+ }
71
+ return result;
72
+ }
73
+
74
+ /** Ajustes sin valor por omisión que el humano da al agregar el plugin (clave → ejemplo). */
75
+ export const requiredSettings = id => BUNDLED[id]?.required || {};
76
+
77
+ /** Valida con el mismo contrato que aplicará el runtime al activar el plugin. */
78
+ export function validateBundledConfig(id, config) {
79
+ const item = BUNDLED[id];
80
+ const missing = Object.keys(item?.required || {}).filter(key => config?.[key] === undefined || config[key] === '');
81
+ if (missing.length) throw new Error(t('{id} needs {keys}. Example: /plugins add {id} {example}', { id, keys: missing.join(', '), example: missing.map(key => `${key}=${item.required[key]}`).join(' ') }));
82
+ if (item?.schema) new Operation({ name: `${id}_config`, description: 'Plugin configuration', input_schema: item.schema, run: () => '' }).validate(config);
83
+ return config;
84
+ }
@@ -2,6 +2,9 @@ import { open, realpath } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { createHash, randomUUID } from 'node:crypto';
5
+ import { t, registerMessages } from './i18n/index.mjs';
6
+
7
+ registerMessages({'Invalid plugin JSON: {detail}': 'Configuración de plugins JSON inválida: {detail}'});
5
8
 
6
9
  const MAX_BYTES = 1024 * 1024;
7
10
  const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
@@ -55,8 +58,9 @@ export async function readPluginConfig(file, { missing = false } = {}) {
55
58
  }
56
59
  let config;
57
60
  try { config = JSON.parse(bytes.toString('utf8')); }
58
- catch (error) { throw new Error(`Configuración de plugins JSON inválida: ${error.message}`); }
59
- fields(config, ['plugins'], 'Configuración de plugins');
61
+ catch (error) { throw new Error(t('Invalid plugin JSON: {detail}', {detail: error.message})); }
62
+ fields(config, ['plugins', 'chrome_workflow'], 'Configuración de plugins');
63
+ if (config.chrome_workflow !== undefined && !['auto', 'raw'].includes(config.chrome_workflow)) throw new Error('chrome_workflow debe ser auto o raw');
60
64
  if (!Array.isArray(config.plugins) || config.plugins.length > 32) throw new Error('plugins debe ser un arreglo con máximo 32 entradas');
61
65
  const seen = new Set();
62
66
  const entries = config.plugins.map(entry => {
@@ -68,28 +72,66 @@ export async function readPluginConfig(file, { missing = false } = {}) {
68
72
  if (entry.config !== undefined && !object(entry.config)) throw new Error(`config debe ser un objeto: ${entry.id}`);
69
73
  return { id: entry.id, module: modulePath(entry.module, path.dirname(absolute)), enabled: entry.enabled ?? true, config: entry.config ?? {} };
70
74
  });
71
- return { file: absolute, hash: hash(bytes), entries };
75
+ return { file: absolute, hash: hash(bytes), entries, ...(config.chrome_workflow ? { chrome_workflow: config.chrome_workflow } : {}) };
76
+ }
77
+
78
+ const MAX_CODE_FILES = 256;
79
+ // Literal specifiers only. Computed imports and packages are reported, never followed.
80
+ const SPECIFIERS = [/\bfrom\s*['"]([^'"\n]+)['"]/g, /\bimport\s*['"]([^'"\n]+)['"]/g, /\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g, /\bnew\s+URL\s*\(\s*['"]([^'"\n]+)['"]\s*,\s*import\.meta\.url\s*\)/g];
81
+
82
+ /**
83
+ * Fingerprint the plugin code without importing it: the entry module plus every
84
+ * local file reachable through literal relative imports or new URL(..., import.meta.url).
85
+ * Packages (bare specifiers) are listed as external and are not covered.
86
+ */
87
+ export async function inspectPluginModule(requested) {
88
+ const entry = await realpath(modulePath(requested, process.cwd()));
89
+ modulePath(entry, process.cwd());
90
+ const root = path.dirname(entry), files = new Map(), external = new Set(), pending = [entry];
91
+ while (pending.length) {
92
+ const file = pending.shift();
93
+ if (files.has(file)) continue;
94
+ if (files.size >= MAX_CODE_FILES) throw new Error(`El plugin alcanza más de ${MAX_CODE_FILES} archivos locales: ${entry}`);
95
+ let bytes;
96
+ try { bytes = await boundedRead(file); }
97
+ catch (error) { if (error.code !== 'ENOENT' || file === entry) throw error; files.set(file, null); continue; }
98
+ files.set(file, hash(bytes));
99
+ if (!/\.(?:mjs|js|cjs)$/.test(file)) continue;
100
+ const source = bytes.toString('utf8');
101
+ for (const pattern of SPECIFIERS) for (const match of source.matchAll(pattern)) {
102
+ const specifier = match[1];
103
+ if (specifier.startsWith('./') || specifier.startsWith('../')) {
104
+ const target = path.resolve(path.dirname(file), specifier.split(/[?#]/)[0]);
105
+ let real; try { real = await realpath(target); } catch (error) { if (error.code !== 'ENOENT') throw error; real = target; }
106
+ if (!files.has(real)) pending.push(real);
107
+ } else if (!specifier.startsWith('node:') && !/^[a-z][a-z0-9+.-]*:/i.test(specifier)) external.add(specifier);
108
+ }
109
+ }
110
+ const list = [...files].map(([file, sha256]) => ({ path: path.relative(root, file).split(path.sep).join('/'), sha256 })).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
111
+ return { module: entry, code_fingerprint: hash(Buffer.from(JSON.stringify(list))), files: list, external: [...external].sort() };
72
112
  }
73
113
 
74
114
  /** Import executes trusted host code. Call only after explicit human plugin trust. */
75
- export async function importPluginEntry(entry) {
115
+ export async function importPluginEntry(entry, { expect } = {}) {
76
116
  pluginId(entry.id);
77
117
  if (entry.enabled === false) throw new Error(`Plugin deshabilitado: ${entry.id}`);
78
118
  const requested = modulePath(entry.module, process.cwd());
79
119
  const module = await realpath(requested);
80
120
  // A symlink must still resolve to a JavaScript entrypoint.
81
121
  modulePath(module, process.cwd());
122
+ const code = await inspectPluginModule(module);
123
+ if (expect && (expect.module !== code.module || expect.code_fingerprint !== code.code_fingerprint)) throw Object.assign(new Error(`El código del plugin ${entry.id} ya no coincide con la confianza registrada; no se importó.`), { code: 'PLUGIN_TRUST_STALE' });
82
124
  const before = await boundedRead(module);
83
125
  const fingerprint = hash(before);
84
126
  const url = pathToFileURL(module);
85
127
  url.searchParams.set('comal_revision', `${fingerprint}-${randomUUID()}`);
86
128
  const imported = await import(url.href);
87
129
  const after = await boundedRead(module);
88
- if (hash(after) !== fingerprint || await realpath(requested) !== module) throw new Error(`El módulo cambió durante la importación: ${entry.id}`);
130
+ if (hash(after) !== fingerprint || await realpath(requested) !== module || (await inspectPluginModule(module)).code_fingerprint !== code.code_fingerprint) throw new Error(`El módulo cambió durante la importación: ${entry.id}`);
89
131
  const definition = imported.default;
90
132
  if (!object(definition) || definition.id !== entry.id || typeof definition.version !== 'string'
91
133
  || !definition.version.trim() || typeof definition.activate !== 'function') {
92
134
  throw new Error(`Plugin inválido ${entry.id}: export default requiere id coincidente, version y activate`);
93
135
  }
94
- return { definition, fingerprint, module };
136
+ return { definition, fingerprint, module, code_fingerprint: code.code_fingerprint, code_files: code.files.length };
95
137
  }