@milpa/comal 0.1.6 → 0.2.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 (137) hide show
  1. package/README.md +19 -1
  2. package/backend/contracts/remote-commands-v1.json +12 -0
  3. package/backend/contracts/remote-workspace-v1.schema.json +287 -0
  4. package/governance/chrome-task-v10.json +323 -0
  5. package/governance/chrome-task-v11.json +355 -0
  6. package/governance/chrome-task-v12.json +387 -0
  7. package/governance/chrome-task-v13.json +419 -0
  8. package/governance/chrome-task-v3.json +99 -0
  9. package/governance/chrome-task-v4.json +131 -0
  10. package/governance/chrome-task-v5.json +163 -0
  11. package/governance/chrome-task-v6.json +195 -0
  12. package/governance/chrome-task-v7.json +227 -0
  13. package/governance/chrome-task-v8.json +259 -0
  14. package/governance/chrome-task-v9.json +291 -0
  15. package/governance/source-task-v1.json +1 -0
  16. package/governance/source-task-v2.json +1 -0
  17. package/package.json +15 -1
  18. package/src/agent-recovery-proof.mjs +12 -1
  19. package/src/agent.mjs +94 -30
  20. package/src/audit.mjs +21 -6
  21. package/src/auto-guard.mjs +48 -1
  22. package/src/chrome-candidate-recovery-proof.mjs +47 -0
  23. package/src/chrome-recovery-proof.mjs +6 -2
  24. package/src/chrome-source-syntax.mjs +93 -0
  25. package/src/cli.mjs +161 -56
  26. package/src/codex-provider.mjs +3 -2
  27. package/src/connection-settings.mjs +204 -0
  28. package/src/context-pressure.mjs +2 -1
  29. package/src/durable-agent-runs.mjs +5 -11
  30. package/src/event-channels.mjs +8 -0
  31. package/src/file-recovery-proof.mjs +11 -3
  32. package/src/file-recovery.mjs +24 -0
  33. package/src/gemini-provider.mjs +6 -1
  34. package/src/git-agent-revision.mjs +12 -0
  35. package/src/git-agent-workspaces.mjs +143 -0
  36. package/src/git-integration-lineage.mjs +20 -0
  37. package/src/git-integration-operations.mjs +200 -0
  38. package/src/git-integration-recovery-proof.mjs +70 -0
  39. package/src/git-page-cli-bridge.mjs +61 -0
  40. package/src/git-page-evidence.mjs +30 -0
  41. package/src/git-page-identity.mjs +10 -0
  42. package/src/git-page-proof.mjs +96 -0
  43. package/src/git-page-settlement.mjs +21 -0
  44. package/src/git-page-verification.mjs +134 -0
  45. package/src/git-plumbing.mjs +212 -0
  46. package/src/git-process-worker.mjs +50 -0
  47. package/src/git-source.mjs +110 -0
  48. package/src/git-workspaces.mjs +519 -0
  49. package/src/goals.mjs +13 -7
  50. package/src/house.mjs +246 -20
  51. package/src/i18n/cli-es.mjs +4 -0
  52. package/src/i18n/es.mjs +35 -0
  53. package/src/judges.mjs +27 -11
  54. package/src/mcp-runtime.mjs +15 -3
  55. package/src/model-controls.mjs +11 -1
  56. package/src/model-deadline.mjs +4 -3
  57. package/src/native-refusal.mjs +38 -0
  58. package/src/onboarding.mjs +88 -24
  59. package/src/openai-provider.mjs +14 -7
  60. package/src/operations.mjs +9 -2
  61. package/src/output-recovery.mjs +48 -0
  62. package/src/peer-tools.mjs +18 -8
  63. package/src/permission-controls.mjs +232 -0
  64. package/src/permission-rules.mjs +312 -0
  65. package/src/permissions.mjs +113 -30
  66. package/src/plugin-catalog.mjs +84 -0
  67. package/src/plugin-config.mjs +48 -6
  68. package/src/plugin-controls.mjs +332 -0
  69. package/src/plugin-notices.mjs +88 -0
  70. package/src/plugin-trust.mjs +104 -0
  71. package/src/plugins/chrome-lab/bridge.mjs +38 -12
  72. package/src/plugins/chrome-lab/client.mjs +103 -25
  73. package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
  74. package/src/plugins/chrome-lab/index.mjs +116 -26
  75. package/src/plugins/chrome-lab/project.mjs +30 -2
  76. package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
  77. package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
  78. package/src/plugins/open-relay/actions.mjs +205 -0
  79. package/src/plugins/open-relay/bound.mjs +47 -0
  80. package/src/plugins/open-relay/client.mjs +158 -0
  81. package/src/plugins/open-relay/dossier.mjs +119 -0
  82. package/src/plugins/open-relay/index.mjs +153 -0
  83. package/src/plugins/open-relay/lookup.mjs +45 -0
  84. package/src/plugins/open-relay/notices.mjs +79 -0
  85. package/src/plugins/open-relay/watch.mjs +127 -0
  86. package/src/plugins.mjs +72 -29
  87. package/src/private-fs.mjs +40 -0
  88. package/src/process-lock.mjs +24 -13
  89. package/src/provider-auth.mjs +14 -3
  90. package/src/provider.mjs +11 -2
  91. package/src/read-only-check.mjs +7 -3
  92. package/src/remote-control.mjs +29 -4
  93. package/src/remote-workspace.mjs +50 -0
  94. package/src/scope-ceiling.mjs +56 -0
  95. package/src/scoped-policy.mjs +39 -0
  96. package/src/secret-login.mjs +68 -0
  97. package/src/session-bus.mjs +41 -7
  98. package/src/session-projections-candidate.mjs +119 -0
  99. package/src/session-projections.mjs +119 -0
  100. package/src/session-read-cache.mjs +23 -3
  101. package/src/sessions.mjs +35 -11
  102. package/src/source-editor-role.mjs +13 -0
  103. package/src/source-ownership.mjs +73 -0
  104. package/src/source-proposal-proof.mjs +55 -0
  105. package/src/source-proposals.mjs +118 -0
  106. package/src/source-requests.mjs +148 -0
  107. package/src/source-task-proof.mjs +24 -0
  108. package/src/source-task-revision-proof.mjs +44 -0
  109. package/src/subagents.mjs +187 -87
  110. package/src/tool-packs.mjs +5 -5
  111. package/src/tools.mjs +82 -15
  112. package/src/trusted-context.mjs +5 -1
  113. package/src/tui/connection-transition.mjs +1 -1
  114. package/src/tui/controller.mjs +8 -4
  115. package/src/tui/discovery.mjs +24 -12
  116. package/src/tui/inbound-blocks.mjs +55 -0
  117. package/src/tui/present.mjs +504 -0
  118. package/src/tui/screen.mjs +49 -13
  119. package/src/windows-private.ps1 +61 -0
  120. package/src/work-assertions.mjs +6 -2
  121. package/src/work-plan.mjs +34 -8
  122. package/src/work-state.mjs +15 -9
  123. package/src/workflows/chrome-page-cases.mjs +156 -0
  124. package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
  125. package/src/workflows/chrome-page-diagnostics.mjs +113 -0
  126. package/src/workflows/chrome-page-law.mjs +48 -0
  127. package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
  128. package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
  129. package/src/workflows/chrome-page-task.mjs +429 -0
  130. package/src/workflows/chrome-page-verifier.mjs +123 -0
  131. package/src/workflows/chrome-source.mjs +73 -0
  132. package/src/workflows/chrome-supplied-inputs.mjs +43 -0
  133. package/src/workflows/chrome-task-adoption.mjs +122 -0
  134. package/src/workflows/chrome-task.mjs +379 -74
  135. package/src/workflows/source-task-v1.mjs +124 -0
  136. package/src/workflows/source-task-v2.mjs +227 -0
  137. package/src/workflows/source-task.mjs +13 -0
@@ -0,0 +1,232 @@
1
+ import { PERMISSION_MODES, BYPASS_PERMISSION_MODE } from './permissions.mjs';
2
+ import { parseRule, ruleText, scopeText, RULE_LISTS } from './permission-rules.mjs';
3
+ import { t, languageInfo, registerMessages } from './i18n/index.mjs';
4
+
5
+ // Controles humanos de permisos: /permissions y /autonomy. Son operaciones
6
+ // audience:'human' sin permiso de herramienta: el modelo, los subagentes y CodeRun
7
+ // no las ven. La autoridad vive con el host: el archivo de reglas está fuera del
8
+ // workspace y sólo estas operaciones (y la tarjeta de permiso) lo escriben.
9
+
10
+ registerMessages({
11
+ 'Only reads and inspects; every change is denied.': 'Sólo lee e inspecciona; todo cambio se deniega.',
12
+ 'Free reads; changes, Bash and external effects ask you.': 'Lecturas libres; cambios, Bash y efectos externos te preguntan.',
13
+ 'Also accepts Write/Edit inside the workspace; Bash and MCP still ask.': 'Además acepta Write/Edit dentro del workspace; Bash y MCP siguen preguntando.',
14
+ 'The guard and the classifier decide based on your request; doubtful actions ask you and destructive ones you did not request are denied.': 'El guardia y el clasificador deciden según tu pedido; lo dudoso te pregunta y lo destructivo sin pedido se deniega.',
15
+ 'Use /permissions [MODE | allow|ask|deny RULE [--session|--project|--user] | remove RULE [--session|--project|--user]].': 'Usa /permissions [MODO | allow|ask|deny REGLA [--sesion|--proyecto|--usuario] | remove REGLA [--sesion|--proyecto|--usuario]].',
16
+ 'Unknown scope {flag}. {usage}': 'Alcance desconocido {flag}. {usage}',
17
+ 'Use /autonomy [observe|assist|edit|autonomous].': 'Usa /autonomy [observar|asistir|editar|autonomo].',
18
+ 'Autonomy: startup bypass (bypassPermissions). It can only be chosen at startup with --dangerously-skip-permissions; /autonomy neither enables nor changes it.': 'Autonomía: sin permisos de arranque (bypassPermissions). Sólo se elige al arrancar con --dangerously-skip-permissions; /autonomy no la activa ni la cambia.',
19
+ 'Autonomy: {level} (mode {mode})': 'Autonomía: {level} (modo {mode})',
20
+ 'Autonomy: {level} → mode {mode}. {summary}': 'Autonomía: {level} → modo {mode}. {summary}',
21
+ 'The deny, ask and allow rules of /permissions apply at every level; confirm:"always" always asks (in observe it is denied).': 'Las reglas deny, ask y allow de /permissions aplican en todos los niveles; confirm:"always" pregunta siempre (en observar se deniega).',
22
+ 'Bash: any command.': 'Bash: cualquier comando.',
23
+ 'The tool {name} with any arguments.': 'La herramienta {name} con cualquier argumento.',
24
+ 'All plugin__{plugin}__* operations.': 'Todas las operaciones plugin__{plugin}__*.',
25
+ 'The operations of group {group} declared by plugin {plugin}.': 'Las operaciones del grupo {group} que declara el plugin {plugin}.',
26
+ 'All tools of MCP server {server} (mcp__{server}__*).': 'Todas las herramientas del servidor MCP {server} (mcp__{server}__*).',
27
+ 'Any Bash command.': 'Cualquier comando Bash.',
28
+ 'A single simple command that starts with the words «{words}». It does not cover compound commands (; && || | &), substitutions ($() ``), expansions ($VAR), redirections (< >) or commands the guard vetoes. As deny/ask, it covers any command containing those words.': 'Un único comando simple que empieza con las palabras «{words}». No cubre comandos compuestos (; && || | &), sustituciones ($() ``), expansiones ($VAR), redirecciones (< >) ni comandos que el guardia veta. Como deny/ask, cubre cualquier comando que contenga esas palabras.',
29
+ 'Exactly the command «{command}». As deny/ask, also when it appears as part of a compound command.': 'Exactamente el comando «{command}». Como deny/ask, también cuando aparece como parte de un comando compuesto.',
30
+ '{tool} with a file_path inside the project matching {glob} (* does not cross /, ** does).': '{tool} con file_path dentro del proyecto que coincide con {glob} (* no cruza /, ** sí).',
31
+ 'WebFetch to {domain} and its subdomains.': 'WebFetch a {domain} y sus subdominios.',
32
+ 'you, on the card (saved «{rule}»)': 'tú, en la tarjeta (guardó «{rule}»)',
33
+ 'rule «{rule}»': 'regla «{rule}»',
34
+ 'guard {guard}': 'guardia {guard}',
35
+ 'startup bypass': 'arranque sin permisos',
36
+ 'session policy ({mode})': 'política de la sesión ({mode})',
37
+ 'you, on the card': 'tú, en la tarjeta',
38
+ 'you or no human': 'tú o sin humano',
39
+ 'plan mode': 'modo plan',
40
+ 'native read': 'lectura nativa',
41
+ 'delegation': 'delegación',
42
+ 'auto (guard/classifier)': 'auto (guardia/clasificador)',
43
+ 'This session has no project; use --session or --user.': 'Esta sesión no tiene proyecto; usa --sesion o --usuario.',
44
+ 'Mode: {mode}': 'Modo: {mode}',
45
+ ' · autonomy {level}': ' · autonomía {level}',
46
+ ' · fixed for this process; only denials apply': ' · fijo para este proceso; sólo las denegaciones aplican',
47
+ ' · change it with /permissions MODE, /autonomy LEVEL or Shift+Tab': ' · cambia con /permissions MODO, /autonomy NIVEL o Shift+Tab',
48
+ 'Rules: {file}': 'Reglas: {file}',
49
+ 'Session (in memory; lost on exit)': 'Sesión (en memoria; se pierden al salir)',
50
+ 'Project {project}': 'Proyecto {project}',
51
+ '(no project)': '(ninguno)',
52
+ 'User (all projects)': 'Usuario (todos los proyectos)',
53
+ ' (no rules)': ' (ninguna)',
54
+ '{count} rule(s) from other projects do not apply here.': '{count} regla(s) de otros proyectos no aplican aquí.',
55
+ 'Startup: --allowed-tools {allowed} · --disallowed-tools {denied}': 'Arranque: --allowed-tools {allowed} · --disallowed-tools {denied}',
56
+ 'Precedence: --disallowed-tools/deny > startup bypass > confirm:"always" > ask > plan/reads/allow > mode.': 'Precedencia: --disallowed-tools/deny > arranque sin permisos > confirm:"always" > ask > plan/lecturas/allow > modo.',
57
+ 'Latest decisions:': 'Últimas decisiones:',
58
+ ' (none in this session)': ' (ninguna en esta sesión)',
59
+ 'There is no permission rule store.': 'No hay almacén de reglas de permiso.',
60
+ 'Broad rule: {why} Denials still win, but this stops asking you.': 'Regla amplia: {why} Las denegaciones siguen ganando, pero esto deja de preguntarte.',
61
+ '{warning} Confirm it in the TUI; -p cannot confirm it.': '{warning} Confírmala en la TUI; -p no puede confirmarla.',
62
+ '{rule} was not saved: you did not confirm the broad rule.': 'No se guardó {rule}: no confirmaste la regla amplia.',
63
+ 'Saved {rule} · in memory only.': 'Guardada {rule} · sólo en memoria.',
64
+ 'Saved {rule} · {file}.': 'Guardada {rule} · {file}.',
65
+ 'Covers: {what}': 'Cubre: {what}',
66
+ 'Note: there is a deny or ask rule with the same pattern, and it wins.': 'Nota: hay una regla deny o ask con el mismo patrón, y gana.',
67
+ 'Note: in plan, allow does not turn changes into reads.': 'Nota: en plan, allow no convierte cambios en lecturas.',
68
+ 'There is no rule «{rule}» in this session, this project or your user.': 'No hay regla «{rule}» en esta sesión, este proyecto ni tu usuario.',
69
+ '«{rule}» is in several scopes ({scopes}); pass --session, --project or --user.': '«{rule}» está en varios alcances ({scopes}); indica --sesion, --proyecto o --usuario.',
70
+ 'There is no rule «{rule}» in {scope}.': 'No hay regla «{rule}» en {scope}.',
71
+ 'Removed {rules}.': 'Quitada {rules}.',
72
+ 'Removed {count} rules: {rules}.': 'Quitadas {rules}.',
73
+ });
74
+
75
+ // Nombres canónicos en inglés; los españoles siguen aceptándose como alias. Sólo el
76
+ // modo (plan, default…) se persiste: el nivel es una etiqueta de la interfaz.
77
+ export const AUTONOMY_LEVELS = Object.freeze([
78
+ Object.freeze({ level: 'observe', mode: 'plan', summary: 'Only reads and inspects; every change is denied.' }),
79
+ Object.freeze({ level: 'assist', mode: 'default', summary: 'Free reads; changes, Bash and external effects ask you.' }),
80
+ Object.freeze({ level: 'edit', mode: 'acceptEdits', summary: 'Also accepts Write/Edit inside the workspace; Bash and MCP still ask.' }),
81
+ Object.freeze({ level: 'autonomous', mode: 'auto', summary: 'The guard and the classifier decide based on your request; doubtful actions ask you and destructive ones you did not request are denied.' }),
82
+ ]);
83
+ const LEVEL_ALIASES = { observe: 'observe', assist: 'assist', edit: 'edit', autonomous: 'autonomous', observar: 'observe', asistir: 'assist', editar: 'edit', autonomo: 'autonomous', 'autónomo': 'autonomous' };
84
+ const LEVEL_NAMES_ES = { observe: 'observar', assist: 'asistir', edit: 'editar', autonomous: 'autonomo' };
85
+ /** Nombre visible del nivel en el idioma de la interfaz. */
86
+ export const levelName = level => languageInfo().language === 'es' ? LEVEL_NAMES_ES[level] || level : level;
87
+ export const levelSummary = item => t(item.summary);
88
+ const SCOPE_FLAGS = { '--session': 'session', '--sesion': 'session', '--sesión': 'session', '--project': 'project', '--proyecto': 'project', '--user': 'user', '--usuario': 'user' };
89
+ const usage = () => t('Use /permissions [MODE | allow|ask|deny RULE [--session|--project|--user] | remove RULE [--session|--project|--user]].');
90
+ const clip = (text, size) => { const value = String(text ?? '').replace(/\s+/g, ' ').trim(); return value.length > size ? value.slice(0, size - 1) + '…' : value; };
91
+
92
+ /** Texto → { name, input } para /permissions. MODE se devuelve como { mode }. */
93
+ export function parsePermissionsCommand(text = '') {
94
+ const trimmed = text.trim();
95
+ if (!trimmed) return { name: 'PermissionsShow', input: {} };
96
+ const [action] = trimmed.split(/\s+/);
97
+ if (PERMISSION_MODES.includes(trimmed)) return { mode: trimmed };
98
+ if (![...RULE_LISTS, 'remove'].includes(action)) throw new Error(usage());
99
+ let rest = trimmed.slice(action.length).trim(), scope;
100
+ const flag = /\s(--[\p{L}-]+)$/u.exec(' ' + rest);
101
+ if (flag) { if (!SCOPE_FLAGS[flag[1]]) throw new Error(t('Unknown scope {flag}. {usage}', { flag: flag[1], usage: usage() })); scope = SCOPE_FLAGS[flag[1]]; rest = rest.slice(0, rest.length - flag[1].length).trim(); }
102
+ if (!rest) throw new Error(usage());
103
+ if (action === 'remove') return { name: 'PermissionRuleRemove', input: { rule: rest, ...(scope ? { scope } : {}) } };
104
+ return { name: 'PermissionRuleAdd', input: { list: action, rule: rest, scope: scope || 'project' } };
105
+ }
106
+
107
+ /** Texto → modo para /autonomy, o null para mostrar. */
108
+ export function parseAutonomy(text = '') {
109
+ const value = text.trim().toLowerCase();
110
+ if (!value) return null;
111
+ const level = LEVEL_ALIASES[value];
112
+ if (!level) throw new Error(t('Use /autonomy [observe|assist|edit|autonomous].'));
113
+ return AUTONOMY_LEVELS.find(item => item.level === level);
114
+ }
115
+ export const autonomyOf = mode => AUTONOMY_LEVELS.find(item => item.mode === mode) || null;
116
+
117
+ export function autonomyText(mode) {
118
+ const current = autonomyOf(mode);
119
+ const lines = [mode === BYPASS_PERMISSION_MODE ? t('Autonomy: startup bypass (bypassPermissions). It can only be chosen at startup with --dangerously-skip-permissions; /autonomy neither enables nor changes it.') : t('Autonomy: {level} (mode {mode})', { level: current ? levelName(current.level) : mode, mode }), ''];
120
+ for (const item of AUTONOMY_LEVELS) lines.push(`${item.mode === mode ? '●' : '○'} ${levelName(item.level).padEnd(10)} ${item.mode.padEnd(11)} ${levelSummary(item)}`);
121
+ lines.push('', t('The deny, ask and allow rules of /permissions apply at every level; confirm:"always" always asks (in observe it is denied).'));
122
+ return lines.join('\n');
123
+ }
124
+ /** Confirmación de /autonomy NIVEL tras cambiar el modo. */
125
+ export const autonomyChangedText = item => t('Autonomy: {level} → mode {mode}. {summary}', { level: levelName(item.level), mode: item.mode, summary: levelSummary(item) });
126
+
127
+ /** Qué cubre exactamente una regla, en palabras. */
128
+ export function explainRule(text) {
129
+ const parsed = parseRule(text);
130
+ switch (parsed.kind) {
131
+ case 'tool': return parsed.name === 'Bash' ? t('Bash: any command.') : t('The tool {name} with any arguments.', { name: parsed.name });
132
+ case 'group': return parsed.group === 'all' ? t('All plugin__{plugin}__* operations.', { plugin: parsed.plugin }) : t('The operations of group {group} declared by plugin {plugin}.', { group: parsed.group, plugin: parsed.plugin });
133
+ case 'mcp-server': return t('All tools of MCP server {server} (mcp__{server}__*).', { server: parsed.server });
134
+ case 'bash-any': return t('Any Bash command.');
135
+ case 'bash-prefix': return t('A single simple command that starts with the words «{words}». It does not cover compound commands (; && || | &), substitutions ($() ``), expansions ($VAR), redirections (< >) or commands the guard vetoes. As deny/ask, it covers any command containing those words.', { words: parsed.words.join(' ') });
136
+ case 'bash-exact': return t('Exactly the command «{command}». As deny/ask, also when it appears as part of a compound command.', { command: parsed.command });
137
+ case 'path': return t('{tool} with a file_path inside the project matching {glob} (* does not cross /, ** does).', { tool: parsed.tool, glob: parsed.glob });
138
+ case 'domain': return t('WebFetch to {domain} and its subdomains.', { domain: parsed.domain });
139
+ default: return text;
140
+ }
141
+ }
142
+
143
+ // Las razones del libro (row.reason) son texto del host en inglés; aquí sólo se elige la etiqueta.
144
+ function decider(row) {
145
+ if (row.rule && String(row.reason || '').startsWith('User confirmation')) return t('you, on the card (saved «{rule}»)', { rule: row.rule });
146
+ if (row.rule) return t('rule «{rule}»', { rule: row.rule });
147
+ if (row.permission_source === 'confirm_always' || /confirm:"always"/.test(row.reason || '')) return 'confirm:"always"';
148
+ const reason = row.reason || '';
149
+ if (row.source === 'plugin_guard') return t('guard {guard}', { guard: row.guard || '' }).trim();
150
+ if (reason === 'Explicit denial rule') return '--disallowed-tools';
151
+ if (reason.startsWith('Explicit startup --dangerously-skip-permissions')) return t('startup bypass');
152
+ if (reason === 'Session policy') return t('session policy ({mode})', { mode: row.mode });
153
+ if (reason.startsWith('User confirmation')) return t('you, on the card');
154
+ if (reason === 'No authorization for this tool') return t('you or no human');
155
+ if (reason.startsWith('Plan mode')) return t('plan mode');
156
+ if (reason.startsWith('Native read-only')) return t('native read');
157
+ if (reason.startsWith('Delegation')) return t('delegation');
158
+ return row.mode === 'auto' ? t('auto (guard/classifier)') : row.mode || '—';
159
+ }
160
+
161
+ export class PermissionControls {
162
+ constructor({ policy, rules = policy.rules, decisions = async () => [], confirm } = {}) {
163
+ Object.assign(this, { policy, decisions, confirm });
164
+ this.rules = rules;
165
+ }
166
+ #scope(scope) {
167
+ if (scope === 'session' || scope === 'user') return scope;
168
+ if (!this.rules?.scope) throw new Error(t('This session has no project; use --session or --user.'));
169
+ return this.rules.scope;
170
+ }
171
+ async show() {
172
+ const policy = this.policy, rules = this.rules, mode = policy.mode, level = autonomyOf(mode);
173
+ const lines = [t('Mode: {mode}', { mode }) + (level ? t(' · autonomy {level}', { level: levelName(level.level) }) : '') + (mode === BYPASS_PERMISSION_MODE ? t(' · fixed for this process; only denials apply') : t(' · change it with /permissions MODE, /autonomy LEVEL or Shift+Tab'))];
174
+ if (rules) lines.push(t('Rules: {file}', { file: rules.file }) + (rules.warning ? ` · ${rules.warning}` : ''));
175
+ const all = rules ? rules.applicable() : [], others = rules ? rules.list().filter(rule => rule.scope !== 'user' && rule.scope !== rules.scope).length : 0;
176
+ const sections = [[t('Session (in memory; lost on exit)'), 'session'], [t('Project {project}', { project: rules?.project || t('(no project)') }), rules?.scope], [t('User (all projects)'), 'user']];
177
+ for (const [title, scope] of sections) {
178
+ const own = all.filter(rule => rule.scope === scope);
179
+ lines.push('', `${title}:`);
180
+ if (!own.length) { lines.push(t(' (no rules)')); continue; }
181
+ for (const list of RULE_LISTS) for (const rule of own.filter(item => item.list === list)) lines.push(` ${list.padEnd(5)} ${rule.rule}`);
182
+ }
183
+ if (others) lines.push('', t('{count} rule(s) from other projects do not apply here.', { count: others }));
184
+ if (policy.allowed?.size || policy.denied?.size) lines.push('', t('Startup: --allowed-tools {allowed} · --disallowed-tools {denied}', { allowed: [...policy.allowed].join(',') || '—', denied: [...policy.denied].join(',') || '—' }));
185
+ lines.push('', t('Precedence: --disallowed-tools/deny > startup bypass > confirm:"always" > ask > plan/reads/allow > mode.'));
186
+ // Los controles humanos (audience:'human') no son decisiones de permiso; se omiten.
187
+ const rows = (await this.decisions()).filter(row => row?.type === 'house_decision' && !String(row.reason || '').startsWith('Explicit user control')).slice(-10);
188
+ lines.push('', t('Latest decisions:'));
189
+ if (!rows.length) lines.push(t(' (none in this session)'));
190
+ for (const row of rows) lines.push(` ${row.allowed ? '✓' : '✗'} ${String(row.timestamp || row.at || '').slice(11, 19) || ' '} ${row.operation} · ${decider(row)} · ${clip(row.reason, 90)}`);
191
+ return lines.join('\n');
192
+ }
193
+ async add({ list, rule, scope = 'project' }, options = {}) {
194
+ if (!this.rules) throw new Error(t('There is no permission rule store.'));
195
+ const parsed = parseRule(rule), target = this.#scope(scope);
196
+ if (parsed.broad && list === 'allow') {
197
+ const warning = t('Broad rule: {why} Denials still win, but this stops asking you.', { why: parsed.broad });
198
+ if (!this.confirm) throw new Error(t('{warning} Confirm it in the TUI; -p cannot confirm it.', { warning }));
199
+ const accepted = await this.confirm('PermissionRuleBroad', { rule, list, scope: scopeText(target), warning }, options);
200
+ if (!(accepted === true || accepted?.allowed === true)) return t('{rule} was not saved: you did not confirm the broad rule.', { rule });
201
+ }
202
+ const saved = await this.rules.add({ list, rule, scope: target });
203
+ const lines = [target === 'session' ? t('Saved {rule} · in memory only.', { rule: ruleText(saved) }) : t('Saved {rule} · {file}.', { rule: ruleText(saved), file: this.rules.file }), t('Covers: {what}', { what: explainRule(rule) })];
204
+ if (list === 'allow' && this.rules.applicable().some(item => item.list !== 'allow' && item.rule === rule)) lines.push(t('Note: there is a deny or ask rule with the same pattern, and it wins.'));
205
+ if (list === 'allow' && this.policy.mode === 'plan') lines.push(t('Note: in plan, allow does not turn changes into reads.'));
206
+ return lines.join('\n');
207
+ }
208
+ async remove({ rule, scope }) {
209
+ if (!this.rules) throw new Error(t('There is no permission rule store.'));
210
+ let target = scope ? this.#scope(scope) : null;
211
+ if (!target) {
212
+ const scopes = [...new Set(this.rules.applicable().filter(item => item.rule === rule).map(item => item.scope))];
213
+ if (!scopes.length) throw new Error(t('There is no rule «{rule}» in this session, this project or your user.', { rule }));
214
+ if (scopes.length > 1) throw new Error(t('«{rule}» is in several scopes ({scopes}); pass --session, --project or --user.', { rule, scopes: scopes.map(scopeText).join(', ') }));
215
+ [target] = scopes;
216
+ }
217
+ const removed = await this.rules.remove({ rule, scope: target });
218
+ if (!removed.length) throw new Error(t('There is no rule «{rule}» in {scope}.', { rule, scope: scopeText(target) }));
219
+ const rules = removed.map(ruleText).join('; ');
220
+ return removed.length > 1 ? t('Removed {count} rules: {rules}.', { count: removed.length, rules }) : t('Removed {rules}.', { rules });
221
+ }
222
+ /** Publica los controles como operaciones humanas: sin permiso de herramienta, invisibles para el modelo. */
223
+ register(registry) {
224
+ const object = (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: false });
225
+ const rule = { type: 'string', minLength: 1, maxLength: 512 }, scope = { enum: ['session', 'project', 'user'] };
226
+ for (const definition of [
227
+ { name: 'PermissionsShow', description: 'Human reads the permission mode, saved rules by scope and the last decisions.', input_schema: object(), run: () => this.show() },
228
+ { name: 'PermissionRuleAdd', description: 'Human saves an allow/ask/deny permission rule for this session, project or user.', input_schema: object({ list: { enum: RULE_LISTS }, rule, scope }, ['list', 'rule', 'scope']), run: (input, options) => this.add(input, options) },
229
+ { name: 'PermissionRuleRemove', description: 'Human removes a saved permission rule.', input_schema: object({ rule, scope }, ['rule']), run: input => this.remove(input) },
230
+ ]) registry.add({ ...definition, audience: 'human', surfaces: ['cli', 'tui'], permission: null, effects: { workspace: 'none', external: 'none', process: 'none', authority: 'permission_control' } });
231
+ }
232
+ }
@@ -0,0 +1,312 @@
1
+ import { lstat, open, readFile, rename, rm } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { ensurePrivateDirectory, assertPrivateDirectory, assertPrivateFile } from './private-fs.mjs';
6
+ import { shellCommands, inspectAutoAction } from './auto-guard.mjs';
7
+ import { t, registerMessages } from './i18n/index.mjs';
8
+
9
+ registerMessages({
10
+ 'Permission rule: empty or too long pattern.': 'Regla de permiso: patrón vacío o demasiado largo.',
11
+ 'Permission rule: invalid pattern {text} (control characters).': 'Regla de permiso: patrón inválido {text} (caracteres de control).',
12
+ 'Bash without parentheses authorizes any command.': 'Bash sin paréntesis autoriza cualquier comando.',
13
+ 'Permission rule: pattern too broad or not supported {text}. Use mcp__server__*, plugin:ID:all or Bash(prefix:*).': 'Regla de permiso: patrón demasiado amplio o no admitido {text}. Usa mcp__servidor__*, plugin:ID:all o Bash(prefijo:*).',
14
+ 'Permission rule: invalid pattern {text}. Only Bash, Edit, Write and WebFetch accept parentheses.': 'Regla de permiso: patrón inválido {text}. Sólo Bash, Edit, Write y WebFetch aceptan paréntesis.',
15
+ 'Permission rule: invalid pattern {text}': 'Regla de permiso: patrón inválido {text}',
16
+ 'Permission rule: invalid pattern {text} (empty parentheses).': 'Regla de permiso: patrón inválido {text} (paréntesis vacíos).',
17
+ 'Permission rule: Bash(:*) has no prefix; use Bash(*) if you really want any command.': 'Regla de permiso: Bash(:*) no tiene prefijo; usa Bash(*) si de verdad quieres cualquier comando.',
18
+ 'Bash(*) authorizes any command, including deletions and network access.': 'Bash(*) autoriza cualquier comando, incluidos borrados y red.',
19
+ 'Permission rule: Bash(:*) has no prefix.': 'Regla de permiso: Bash(:*) no tiene prefijo.',
20
+ 'Permission rule: the prefix of {text} only accepts plain words, without operators, quotes or wildcards.': 'Regla de permiso: el prefijo de {text} sólo admite palabras simples, sin operadores, comillas ni comodines.',
21
+ '{word} runs other programs: the prefix is equivalent to full Bash.': '{word} ejecuta otros programas: el prefijo equivale a Bash completo.',
22
+ 'Permission rule: {text} is not a prefix. Use Bash(prefix:*) for a prefix or the exact command.': 'Regla de permiso: {text} no es un prefijo. Usa Bash(prefijo:*) para un prefijo o el comando exacto.',
23
+ 'Permission rule: {text} must be WebFetch(domain:example.com).': 'Regla de permiso: {text} debe ser WebFetch(domain:ejemplo.com).',
24
+ 'Permission rule: {text} must be a path or glob relative to the project, without .. or absolute paths.': 'Regla de permiso: {text} debe ser una ruta o glob relativo al proyecto, sin .. ni rutas absolutas.',
25
+ '{target} covers the whole project.': '{target} cubre todo el proyecto.',
26
+ '{file} is inside the workspace; it is ignored because the model tools could write it. Move XDG_CONFIG_HOME outside the project.': '{file} está dentro del workspace; se ignora porque las herramientas del modelo podrían escribirlo. Mueve XDG_CONFIG_HOME fuera del proyecto.',
27
+ '{file} exceeds 256 KiB.': '{file} supera 256 KiB.',
28
+ '{file} is inside the workspace; it is not written.': '{file} está dentro del workspace; no se escribe.',
29
+ 'Invalid permission rule.': 'Regla de permiso inválida.',
30
+ 'Permission rule: unknown field {key}': 'Regla de permiso: campo desconocido {key}',
31
+ 'Permission rule: list must be allow, ask or deny.': 'Regla de permiso: list debe ser allow, ask o deny.',
32
+ 'Permission rule: invalid pattern {rule}. {reason}': 'Regla de permiso: patrón inválido {rule}. {reason}',
33
+ 'Permission rule: scope must be user or project:<absolute path>.': 'Regla de permiso: scope debe ser user o project:<ruta absoluta>.',
34
+ 'Permission rule: added_at and source:"human" are required.': 'Regla de permiso: added_at y source:"human" requeridos.',
35
+ 'Unrecognized permission rules format.': 'Formato de reglas de permiso no reconocido.',
36
+ 'Permission rules ignored: {reason}': 'Reglas de permiso ignoradas: {reason}',
37
+ '{warning} Fix or delete {file} manually.': '{warning} Corrige o elimina {file} manualmente.',
38
+ 'At most {max} rules.': 'Máximo {max} reglas.',
39
+ 'At most {max} session rules.': 'Máximo {max} reglas de sesión.',
40
+ 'scope:user': 'usuario', 'scope:session': 'sesión',
41
+ '{list} rule «{rule}» ({scope})': 'regla {list} «{rule}» ({scope})',
42
+ });
43
+
44
+ // Reglas de permiso guardadas por el humano, por usuario y fuera del workspace.
45
+ // Las lee PermissionPolicy; las escriben sólo controles humanos (/plugins allow,
46
+ // /permissions y «sí, siempre» desde la tarjeta de permiso). Formato:
47
+ // { version: 1, rules: [{ list: 'allow'|'ask'|'deny', rule, scope: 'user'|'project:<ruta>', added_at, source: 'human' }] }
48
+ // Las reglas de sesión (scope 'session') viven sólo en memoria y nunca se escriben.
49
+ // Diferencia deliberada con Claude Code: no hay archivo de reglas dentro del repo,
50
+ // porque las herramientas del modelo podrían editarlo. Las de proyecto se guardan
51
+ // por usuario, ligadas a la ruta absoluta del proyecto.
52
+ const VERSION = 1, MAX_BYTES = 256 * 1024, MAX_RULES = 512, MAX_RULE = 512;
53
+ export const RULE_LISTS = Object.freeze(['deny', 'ask', 'allow']);
54
+ const TOOL = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/, GROUP = /^plugin:([a-z][a-z0-9]*(?:-[a-z0-9]+)*):(observe|actions|all)$/;
55
+ const MCP_SERVER = /^mcp__([a-zA-Z0-9_-]{1,32})__\*$/, PARAMETRIC = /^(Bash|Edit|Write|WebFetch)\(([\s\S]*)\)$/;
56
+ const DOMAIN = /^domain:((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,62})$/;
57
+ // Primeras palabras que ejecutan otra cosa: un prefijo con ellas equivale a Bash completo.
58
+ const WRAPPERS = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'env', 'xargs', 'eval', 'exec', 'command', 'builtin', 'sudo', 'su', 'doas', 'timeout', 'nice', 'nohup', 'busybox', 'time', 'watch', 'stdbuf', 'setsid', 'npx', 'pnpx', 'bunx', 'node', 'deno', 'bun', 'python', 'python3', 'ruby', 'perl', 'php', 'lua', 'source', '.']);
59
+ const fail = (message, code = 'PERMISSION_RULES_INVALID', extra = {}) => Object.assign(new Error(message), { code, ...extra });
60
+ // Errores de patrón que validateRule muestra tal cual (sin volver a prefijarlos).
61
+ const invalidPattern = message => fail(message, 'PERMISSION_RULES_INVALID', { invalidPattern: true });
62
+ const inside = (root, file) => { const relative = path.relative(root, file); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); };
63
+
64
+ export const permissionRulesFile = (env = process.env) => path.resolve(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'comal', 'permissions.json');
65
+ export const projectScope = cwd => `project:${path.resolve(cwd)}`;
66
+ export const parseGroup = rule => { const match = GROUP.exec(rule); return match ? { plugin: match[1], group: match[2] } : null; };
67
+
68
+ /**
69
+ * Analiza una regla. Formas admitidas:
70
+ * Tool nombre exacto (incluye mcp__srv__tool y plugin__ID__Op)
71
+ * mcp__srv__* todas las herramientas de un servidor MCP
72
+ * plugin:ID:observe|actions|all
73
+ * Bash(prefijo:*) un único comando simple cuyas primeras palabras son el prefijo
74
+ * Bash(comando exacto) el texto exacto del comando
75
+ * Bash(*) cualquier Bash (amplia)
76
+ * Edit(glob) / Write(glob) file_path relativo al proyecto
77
+ * WebFetch(domain:host) host exacto o subdominios
78
+ * Devuelve { kind, ..., broad } o lanza con un mensaje que explica el rechazo.
79
+ */
80
+ export function parseRule(text) {
81
+ if (typeof text !== 'string' || !text.length || text.length > MAX_RULE) throw fail(t('Permission rule: empty or too long pattern.'));
82
+ if (/[\x00-\x1f\x7f]/.test(text)) throw invalidPattern(t('Permission rule: invalid pattern {text} (control characters).', { text: JSON.stringify(text) }));
83
+ if (TOOL.test(text)) return { kind: 'tool', name: text, broad: text === 'Bash' ? t('Bash without parentheses authorizes any command.') : null };
84
+ const group = parseGroup(text); if (group) return { kind: 'group', ...group, broad: null };
85
+ const server = MCP_SERVER.exec(text); if (server) return { kind: 'mcp-server', server: server[1], broad: null };
86
+ const match = PARAMETRIC.exec(text);
87
+ if (!match) {
88
+ if (text.includes('*')) throw fail(t('Permission rule: pattern too broad or not supported {text}. Use mcp__server__*, plugin:ID:all or Bash(prefix:*).', { text }));
89
+ if (/^[A-Za-z][A-Za-z0-9_-]*\(/.test(text)) throw invalidPattern(t('Permission rule: invalid pattern {text}. Only Bash, Edit, Write and WebFetch accept parentheses.', { text }));
90
+ throw invalidPattern(t('Permission rule: invalid pattern {text}', { text }));
91
+ }
92
+ const [, tool, raw] = match, content = raw.trim();
93
+ if (!content) throw invalidPattern(t('Permission rule: invalid pattern {text} (empty parentheses).', { text }));
94
+ if (tool === 'Bash') {
95
+ if (content === '*' || content === ':*') { if (content === ':*') throw fail(t('Permission rule: Bash(:*) has no prefix; use Bash(*) if you really want any command.')); return { kind: 'bash-any', broad: t('Bash(*) authorizes any command, including deletions and network access.') }; }
96
+ if (content.endsWith(':*')) {
97
+ const prefix = content.slice(0, -2).trim();
98
+ if (!prefix) throw fail(t('Permission rule: Bash(:*) has no prefix.'));
99
+ if (/[;&|<>$`\\(){}*?[\]'"#~=]/.test(prefix)) throw fail(t('Permission rule: the prefix of {text} only accepts plain words, without operators, quotes or wildcards.', { text }));
100
+ const words = prefix.split(/\s+/);
101
+ return { kind: 'bash-prefix', words, broad: WRAPPERS.has(path.basename(words[0])) ? t('{word} runs other programs: the prefix is equivalent to full Bash.', { word: words[0] }) : null };
102
+ }
103
+ if (/(?:^|\s)\*$/.test(content)) throw fail(t('Permission rule: {text} is not a prefix. Use Bash(prefix:*) for a prefix or the exact command.', { text }));
104
+ return { kind: 'bash-exact', command: content, broad: null };
105
+ }
106
+ if (tool === 'WebFetch') {
107
+ const domain = DOMAIN.exec(content.toLowerCase());
108
+ if (!domain) throw fail(t('Permission rule: {text} must be WebFetch(domain:example.com).', { text }));
109
+ return { kind: 'domain', tool, domain: domain[1], broad: null };
110
+ }
111
+ // Edit/Write: glob relativo al proyecto, sin salir de él.
112
+ const glob = content.replace(/^\.\//, '');
113
+ if (path.isAbsolute(glob) || glob.startsWith('~') || glob.includes('\\') || glob.split('/').some(part => part === '..' || part === '')) throw fail(t('Permission rule: {text} must be a path or glob relative to the project, without .. or absolute paths.', { text }));
114
+ return { kind: 'path', tool, glob, broad: /^(?:\*\*|\*\*\/\*|\*)$/.test(glob) ? t('{target} covers the whole project.', { target: `${tool}(${glob})` }) : null };
115
+ }
116
+ export function validateRuleText(text) { parseRule(text); return text; }
117
+
118
+ const globRegex = glob => new RegExp('^' + glob.split(/(\*\*\/|\*\*|\*|\?)/).map(part => part === '**/' ? '(?:.*/)?' : part === '**' ? '.*' : part === '*' ? '[^/]*' : part === '?' ? '[^/]' : part.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('') + '$');
119
+ const startsWithWords = (tokens, words) => tokens.length >= words.length && words.every((word, index) => tokens[index] === word);
120
+
121
+ /**
122
+ * ¿Cubre la regla Bash este comando? allow es estricto, deny/ask conservadores:
123
+ * - allow con prefijo: exactamente un comando simple (sin ; && || | & ni saltos de línea),
124
+ * sin sustituciones, expansiones ni redirecciones (shellCommands devuelve null), sin
125
+ * veto duro del guardia, y cuyas primeras palabras son exactamente las del prefijo.
126
+ * - allow exacto: el texto completo, igual tras recortar espacios.
127
+ * - deny/ask: basta con que el prefijo o el comando aparezca en cualquier subcomando;
128
+ * si el comando no se puede analizar, se busca la secuencia de palabras en todo el texto.
129
+ */
130
+ export function bashCovers(parsed, command, { list = 'allow', cwd = process.cwd() } = {}) {
131
+ if (typeof command !== 'string') return false;
132
+ if (parsed.kind === 'bash-any') return true;
133
+ const commands = shellCommands(command);
134
+ if (list === 'allow') {
135
+ if (parsed.kind === 'bash-exact') return command.trim() === parsed.command;
136
+ if (!commands || commands.length !== 1 || /[\r\n]/.test(command)) return false;
137
+ if (inspectAutoAction('Bash', { command }, cwd).hard) return false;
138
+ return startsWithWords(commands[0], parsed.words);
139
+ }
140
+ const words = parsed.kind === 'bash-exact' ? parsed.command.split(/\s+/) : parsed.words;
141
+ if (parsed.kind === 'bash-exact' && command.trim() === parsed.command) return true;
142
+ const exact = tokens => parsed.kind === 'bash-exact' ? tokens.join(' ') === words.join(' ') : startsWithWords(tokens, words);
143
+ if (commands?.some(tokens => exact(tokens) || exact([path.basename(tokens[0] || ''), ...tokens.slice(1)]))) return true;
144
+ const loose = command.split(/[\s;&|()<>`$"'{}\\]+/).filter(Boolean).map(word => word.includes('/') ? path.basename(word) : word);
145
+ return loose.some((_, index) => startsWithWords(loose.slice(index), words));
146
+ }
147
+
148
+ /** ¿Cubre la regla analizada esta invocación? */
149
+ export function ruleCovers(parsed, name, input, { list = 'allow', project = process.cwd(), groups = () => [] } = {}) {
150
+ switch (parsed.kind) {
151
+ case 'tool': return parsed.name === name;
152
+ case 'group': return name.startsWith(`plugin__${parsed.plugin}__`) && (parsed.group === 'all' || (groups(parsed.plugin, parsed.group) || []).includes(name));
153
+ case 'mcp-server': return name.startsWith(`mcp__${parsed.server}__`);
154
+ case 'bash-any': return name === 'Bash';
155
+ case 'bash-prefix': case 'bash-exact': return name === 'Bash' && bashCovers(parsed, input?.command, { list, cwd: project });
156
+ case 'path': {
157
+ if (name !== parsed.tool || typeof input?.file_path !== 'string') return false;
158
+ const root = path.resolve(project), file = path.resolve(root, input.file_path), relative = path.relative(root, file);
159
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return false;
160
+ return globRegex(parsed.glob).test(relative.split(path.sep).join('/'));
161
+ }
162
+ case 'domain': {
163
+ if (name !== 'WebFetch' || typeof input?.url !== 'string') return false;
164
+ let host; try { host = new URL(input.url).hostname.toLowerCase().replace(/\.$/, ''); } catch { return list !== 'allow'; }
165
+ return host === parsed.domain || host.endsWith(`.${parsed.domain}`);
166
+ }
167
+ default: return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Regla más específica razonable para «sí, siempre». null si no hay una segura.
173
+ * Bash: prefijo del primer comando simple (ejecutable y, si la hay, una suborden);
174
+ * comandos compuestos, opacos o destructivos → el comando exacto.
175
+ */
176
+ export function suggestRule(name, input, options = {}) {
177
+ const rule = suggestion(name, input, options);
178
+ if (!rule) return null;
179
+ try { return parseRule(rule).broad ? null : rule; } catch { return null; }
180
+ }
181
+ function suggestion(name, input, { project = process.cwd() } = {}) {
182
+ if (name === 'Bash') {
183
+ const command = typeof input?.command === 'string' ? input.command.trim() : '';
184
+ if (!command || /[\x00-\x1f\x7f]/.test(command) || command.length > MAX_RULE - 6) return null;
185
+ const commands = shellCommands(command), inspection = inspectAutoAction('Bash', { command }, project);
186
+ if (inspection.hard) return null;
187
+ if (commands?.length === 1 && !inspection.opaque && !inspection.destructive) {
188
+ const [first, second] = commands[0], words = [first];
189
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.+-]*$/.test(first) || WRAPPERS.has(first)) return `Bash(${command})`;
190
+ if (second && /^[a-z][a-z0-9-]*$/.test(second)) words.push(second);
191
+ return `Bash(${words.join(' ')}:*)`;
192
+ }
193
+ return `Bash(${command})`;
194
+ }
195
+ if ((name === 'Edit' || name === 'Write') && typeof input?.file_path === 'string') {
196
+ const root = path.resolve(project), relative = path.relative(root, path.resolve(root, input.file_path));
197
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || /[*?[\]\x00-\x1f]/.test(relative)) return null;
198
+ return `${name}(${relative.split(path.sep).join('/')})`;
199
+ }
200
+ return TOOL.test(name) ? name : null;
201
+ }
202
+
203
+ /** Lectura acotada de un JSON privado (600, dueño actual, directorio 700). Ausente → undefined. */
204
+ export async function readPrivateJson(file, { workspace } = {}) {
205
+ if (workspace && inside(path.resolve(workspace), path.resolve(file))) throw fail(t('{file} is inside the workspace; it is ignored because the model tools could write it. Move XDG_CONFIG_HOME outside the project.', { file }), 'PRIVATE_JSON_IN_WORKSPACE');
206
+ let info;
207
+ try { info = await lstat(file); } catch (error) { if (error.code === 'ENOENT') return undefined; throw error; }
208
+ await assertPrivateDirectory(path.dirname(file)); await assertPrivateFile(file, info);
209
+ if (info.size > MAX_BYTES) throw fail(t('{file} exceeds 256 KiB.', { file }));
210
+ return JSON.parse(await readFile(file, 'utf8'));
211
+ }
212
+
213
+ /** Escritura atómica: temporal 600 en el mismo directorio privado, fsync y rename. */
214
+ export async function writePrivateJson(file, value, { workspace } = {}) {
215
+ if (workspace && inside(path.resolve(workspace), path.resolve(file))) throw fail(t('{file} is inside the workspace; it is not written.', { file }), 'PRIVATE_JSON_IN_WORKSPACE');
216
+ const directory = path.dirname(file);
217
+ await ensurePrivateDirectory(directory); await assertPrivateDirectory(directory);
218
+ const temporary = path.join(directory, `.${path.basename(file)}-${randomUUID()}.tmp`);
219
+ const handle = await open(temporary, 'wx', 0o600);
220
+ try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); } finally { await handle.close(); }
221
+ try { await rename(temporary, file); } catch (error) { await rm(temporary, { force: true }); throw error; }
222
+ }
223
+
224
+ export function validateRule(value, { session = false } = {}) {
225
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw fail(t('Invalid permission rule.'));
226
+ for (const key of Object.keys(value)) if (!['list', 'rule', 'scope', 'added_at', 'source'].includes(key)) throw fail(t('Permission rule: unknown field {key}', { key }));
227
+ if (!RULE_LISTS.includes(value.list)) throw fail(t('Permission rule: list must be allow, ask or deny.'));
228
+ try { parseRule(value.rule); } catch (error) { throw fail(error.invalidPattern ? error.message : t('Permission rule: invalid pattern {rule}. {reason}', { rule: value.rule, reason: error.message })); }
229
+ if (!(session && value.scope === 'session') && value.scope !== 'user' && !(typeof value.scope === 'string' && value.scope.startsWith('project:') && path.isAbsolute(value.scope.slice(8)))) throw fail(t('Permission rule: scope must be user or project:<absolute path>.'));
230
+ if (typeof value.added_at !== 'string' || value.source !== 'human') throw fail(t('Permission rule: added_at and source:"human" are required.'));
231
+ return { list: value.list, rule: value.rule, scope: value.scope, added_at: value.added_at, source: 'human' };
232
+ }
233
+
234
+ const key = rule => `${rule.list}\u0000${rule.rule}\u0000${rule.scope}`;
235
+
236
+ /**
237
+ * Almacén y juez de reglas. `groups(plugin, group)` devuelve los nombres de un grupo
238
+ * declarado (p. ej. plugin:chrome-lab:observe); `plugin:ID:all` cubre plugin__ID__*.
239
+ * `gate(name)` puede vetar una regla allow (p. ej. si el código del plugin no es el confiado);
240
+ * deny y ask nunca se vetan.
241
+ */
242
+ export class PermissionRules {
243
+ #rules = [];
244
+ #session = [];
245
+ #parsed = new Map();
246
+ constructor({ file = permissionRulesFile(), workspace, project = workspace, groups = () => [], gate = () => true } = {}) {
247
+ Object.assign(this, { file: path.resolve(file), workspace, groups, gate });
248
+ this.project = project ? path.resolve(project) : null;
249
+ this.scope = project ? projectScope(project) : null; this.revision = 0; this.warning = null; this.unsafe = false;
250
+ }
251
+ async load() {
252
+ this.warning = null; this.unsafe = false;
253
+ try {
254
+ const data = await readPrivateJson(this.file, { workspace: this.workspace });
255
+ if (data !== undefined && (!data || data.version !== VERSION || !Array.isArray(data.rules) || data.rules.length > MAX_RULES)) throw fail(t('Unrecognized permission rules format.'));
256
+ this.#rules = (data?.rules || []).map(rule => validateRule(rule));
257
+ } catch (error) { this.#rules = []; this.unsafe = true; this.warning = t('Permission rules ignored: {reason}', { reason: error.message }); }
258
+ this.revision++; return this.list();
259
+ }
260
+ /** Reglas guardadas en el archivo (todas, de cualquier proyecto). */
261
+ list() { return this.#rules.map(rule => ({ ...rule })); }
262
+ /** Reglas de sesión: en memoria, nunca escritas. */
263
+ sessionRules() { return this.#session.map(rule => ({ ...rule })); }
264
+ applicable() { return [...this.#session, ...this.#rules.filter(rule => rule.scope === 'user' || rule.scope === this.scope)]; }
265
+ #parse(text) { if (!this.#parsed.has(text)) this.#parsed.set(text, parseRule(text)); return this.#parsed.get(text); }
266
+ matches(rule, name, input, list = rule.list || 'allow') {
267
+ let parsed; try { parsed = this.#parse(rule.rule); } catch { return false; }
268
+ return ruleCovers(parsed, name, input, { list, project: this.project || process.cwd(), groups: this.groups });
269
+ }
270
+ /** Precedencia: deny > ask > allow. Devuelve la regla decisiva o undefined. */
271
+ decide(name, input) {
272
+ const found = this.applicable().filter(rule => this.matches(rule, name, input));
273
+ const deny = found.find(rule => rule.list === 'deny'); if (deny) return deny;
274
+ const ask = found.find(rule => rule.list === 'ask'); if (ask) return ask;
275
+ return found.find(rule => rule.list === 'allow' && this.gate(name));
276
+ }
277
+ /** Reemplaza las reglas que cumplen `remove` por `add`, en una sola escritura atómica. */
278
+ async update({ remove = () => false, add = [] } = {}) {
279
+ if (this.unsafe) throw fail(t('{warning} Fix or delete {file} manually.', { warning: this.warning, file: this.file }), 'PERMISSION_RULES_UNSAFE');
280
+ const stamp = new Date().toISOString();
281
+ const next = [...this.#rules.filter(rule => !remove(rule)), ...add.map(rule => validateRule({ added_at: stamp, source: 'human', ...rule }))];
282
+ const unique = [...new Map(next.map(rule => [key(rule), rule])).values()];
283
+ if (unique.length > MAX_RULES) throw fail(t('At most {max} rules.', { max: MAX_RULES }));
284
+ await writePrivateJson(this.file, { version: VERSION, rules: unique }, { workspace: this.workspace });
285
+ this.#rules = unique; this.revision++; return this.list();
286
+ }
287
+ /** Agrega una regla en `scope` (session|user|project:<ruta>); las de sesión no tocan el archivo. */
288
+ async add({ list, rule, scope }) {
289
+ if (scope === 'session') {
290
+ const value = validateRule({ list, rule, scope, added_at: new Date().toISOString(), source: 'human' }, { session: true });
291
+ if (!this.#session.some(item => key(item) === key(value))) { if (this.#session.length >= MAX_RULES) throw fail(t('At most {max} session rules.', { max: MAX_RULES })); this.#session.push(value); }
292
+ this.revision++; return value;
293
+ }
294
+ await this.update({ add: [{ list, rule, scope }] });
295
+ return this.#rules.find(item => key(item) === key({ list, rule, scope }));
296
+ }
297
+ /** Quita la regla `rule` en `scope` (de cualquier lista, o de `list` si se indica). Devuelve las retiradas. */
298
+ async remove({ rule, scope, list }) {
299
+ const hit = item => item.rule === rule && item.scope === scope && (!list || item.list === list);
300
+ if (scope === 'session') { const removed = this.#session.filter(hit); this.#session = this.#session.filter(item => !hit(item)); if (removed.length) this.revision++; return removed; }
301
+ const removed = this.#rules.filter(hit); if (removed.length) await this.update({ remove: hit });
302
+ return removed.map(item => ({ ...item }));
303
+ }
304
+ }
305
+
306
+ // describeRule/scopeLabel van en razones de decisión que también ve el modelo: inglés fijo.
307
+ // scopeText/ruleText son para el humano y siguen el idioma de la interfaz.
308
+ export const scopeLabel = scope => String(scope);
309
+ export const describeRule = rule => `${rule.list} rule «${rule.rule}» (${scopeLabel(rule.scope)})`;
310
+ const SCOPE_NAMES = { user: () => t('scope:user'), session: () => t('scope:session') };
311
+ export const scopeText = scope => SCOPE_NAMES[scope] ? SCOPE_NAMES[scope]().replace(/^scope:/, '') : String(scope);
312
+ export const ruleText = rule => t('{list} rule «{rule}» ({scope})', { list: rule.list, rule: rule.rule, scope: scopeText(rule.scope) });