@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.
- package/README.md +19 -1
- package/backend/contracts/remote-commands-v1.json +12 -0
- package/governance/chrome-task-v10.json +323 -0
- package/governance/chrome-task-v11.json +355 -0
- package/governance/chrome-task-v12.json +387 -0
- package/governance/chrome-task-v13.json +419 -0
- package/governance/chrome-task-v3.json +99 -0
- package/governance/chrome-task-v4.json +131 -0
- package/governance/chrome-task-v5.json +163 -0
- package/governance/chrome-task-v6.json +195 -0
- package/governance/chrome-task-v7.json +227 -0
- package/governance/chrome-task-v8.json +259 -0
- package/governance/chrome-task-v9.json +291 -0
- package/governance/source-task-v1.json +1 -0
- package/governance/source-task-v2.json +1 -0
- package/package.json +14 -1
- package/src/agent-recovery-proof.mjs +12 -1
- package/src/agent.mjs +94 -30
- package/src/audit.mjs +21 -6
- package/src/auto-guard.mjs +48 -1
- package/src/chrome-candidate-recovery-proof.mjs +47 -0
- package/src/chrome-recovery-proof.mjs +6 -2
- package/src/chrome-source-syntax.mjs +93 -0
- package/src/cli.mjs +158 -54
- package/src/codex-provider.mjs +3 -2
- package/src/connection-settings.mjs +204 -0
- package/src/context-pressure.mjs +2 -1
- package/src/durable-agent-runs.mjs +5 -11
- package/src/event-channels.mjs +8 -0
- package/src/file-recovery-proof.mjs +11 -3
- package/src/file-recovery.mjs +24 -0
- package/src/gemini-provider.mjs +6 -1
- package/src/git-agent-revision.mjs +12 -0
- package/src/git-agent-workspaces.mjs +143 -0
- package/src/git-integration-lineage.mjs +20 -0
- package/src/git-integration-operations.mjs +200 -0
- package/src/git-integration-recovery-proof.mjs +70 -0
- package/src/git-page-cli-bridge.mjs +61 -0
- package/src/git-page-evidence.mjs +30 -0
- package/src/git-page-identity.mjs +10 -0
- package/src/git-page-proof.mjs +96 -0
- package/src/git-page-settlement.mjs +21 -0
- package/src/git-page-verification.mjs +134 -0
- package/src/git-plumbing.mjs +212 -0
- package/src/git-process-worker.mjs +50 -0
- package/src/git-source.mjs +110 -0
- package/src/git-workspaces.mjs +519 -0
- package/src/goals.mjs +13 -7
- package/src/house.mjs +246 -20
- package/src/i18n/cli-es.mjs +4 -0
- package/src/i18n/es.mjs +35 -0
- package/src/judges.mjs +27 -11
- package/src/mcp-runtime.mjs +15 -3
- package/src/model-controls.mjs +11 -1
- package/src/model-deadline.mjs +4 -3
- package/src/native-refusal.mjs +38 -0
- package/src/onboarding.mjs +88 -24
- package/src/openai-provider.mjs +14 -7
- package/src/operations.mjs +9 -2
- package/src/output-recovery.mjs +48 -0
- package/src/peer-tools.mjs +18 -8
- package/src/permission-controls.mjs +232 -0
- package/src/permission-rules.mjs +312 -0
- package/src/permissions.mjs +113 -30
- package/src/plugin-catalog.mjs +84 -0
- package/src/plugin-config.mjs +48 -6
- package/src/plugin-controls.mjs +332 -0
- package/src/plugin-notices.mjs +88 -0
- package/src/plugin-trust.mjs +104 -0
- package/src/plugins/chrome-lab/bridge.mjs +38 -12
- package/src/plugins/chrome-lab/client.mjs +103 -25
- package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
- package/src/plugins/chrome-lab/index.mjs +116 -26
- package/src/plugins/chrome-lab/project.mjs +30 -2
- package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
- package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
- package/src/plugins/open-relay/actions.mjs +205 -0
- package/src/plugins/open-relay/bound.mjs +47 -0
- package/src/plugins/open-relay/client.mjs +158 -0
- package/src/plugins/open-relay/dossier.mjs +119 -0
- package/src/plugins/open-relay/index.mjs +153 -0
- package/src/plugins/open-relay/lookup.mjs +45 -0
- package/src/plugins/open-relay/notices.mjs +79 -0
- package/src/plugins/open-relay/watch.mjs +127 -0
- package/src/plugins.mjs +72 -29
- package/src/private-fs.mjs +46 -0
- package/src/process-lock.mjs +24 -13
- package/src/provider-auth.mjs +14 -3
- package/src/provider.mjs +11 -2
- package/src/read-only-check.mjs +7 -3
- package/src/scope-ceiling.mjs +56 -0
- package/src/scoped-policy.mjs +39 -0
- package/src/secret-login.mjs +68 -0
- package/src/session-bus.mjs +41 -7
- package/src/session-projections-candidate.mjs +119 -0
- package/src/session-projections.mjs +119 -0
- package/src/session-read-cache.mjs +23 -3
- package/src/sessions.mjs +35 -11
- package/src/source-editor-role.mjs +13 -0
- package/src/source-ownership.mjs +76 -0
- package/src/source-proposal-proof.mjs +55 -0
- package/src/source-proposals.mjs +118 -0
- package/src/source-requests.mjs +148 -0
- package/src/source-task-proof.mjs +24 -0
- package/src/source-task-revision-proof.mjs +44 -0
- package/src/subagents.mjs +187 -87
- package/src/tool-packs.mjs +5 -5
- package/src/tools.mjs +82 -15
- package/src/trusted-context.mjs +5 -1
- package/src/tui/connection-transition.mjs +1 -1
- package/src/tui/controller.mjs +8 -4
- package/src/tui/discovery.mjs +24 -12
- package/src/tui/inbound-blocks.mjs +55 -0
- package/src/tui/present.mjs +504 -0
- package/src/tui/screen.mjs +49 -13
- package/src/windows-private.ps1 +63 -0
- package/src/work-assertions.mjs +6 -2
- package/src/work-plan.mjs +34 -8
- package/src/work-state.mjs +15 -9
- package/src/workflows/chrome-page-cases.mjs +156 -0
- package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
- package/src/workflows/chrome-page-diagnostics.mjs +113 -0
- package/src/workflows/chrome-page-law.mjs +48 -0
- package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
- package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
- package/src/workflows/chrome-page-task.mjs +429 -0
- package/src/workflows/chrome-page-verifier.mjs +123 -0
- package/src/workflows/chrome-source.mjs +73 -0
- package/src/workflows/chrome-supplied-inputs.mjs +43 -0
- package/src/workflows/chrome-task-adoption.mjs +122 -0
- package/src/workflows/chrome-task.mjs +379 -74
- package/src/workflows/source-task-v1.mjs +124 -0
- package/src/workflows/source-task-v2.mjs +227 -0
- package/src/workflows/source-task.mjs +13 -0
package/src/permissions.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
+
const answer=normalizeAnswer(await waitWithAbort(confirm(name,input,{signal,questionId,...(offer?{remember:offer}:{})}),signal),offer);
|
|
70
141
|
signal?.throwIfAborted();
|
|
71
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return { allowed, reason: verdict.reason
|
|
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
|
+
}
|
package/src/plugin-config.mjs
CHANGED
|
@@ -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(
|
|
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
|
}
|