external-agent.mjs
637 lines 18.9 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 14 hours ago
1 /**
2 * External-agent gate — scoped grants, vault tool allowlists, grant mint/revoke/list
3 * (Phase 7A-L2b).
4 *
5 * Grants are server-minted `knowtation.flow_external_grant/v0` records. Tool
6 * allowlists live in vault policy; `external_tool` skill-refs activate only when
7 * grant + allowlist + approved canonical Flow version all match.
8 *
9 * `FLOW_EXTERNAL_AGENT_ENABLED` and `FLOW_HOSTED_PROJECTION_ENABLED` default **off**.
10 *
11 * @see docs/FLOW-EXTERNAL-AGENT-CONTRACT-7A-L2.md
12 */
13
14 import fs from 'fs';
15 import path from 'path';
16 import { createHash, randomBytes } from 'crypto';
17
18 import { getFlow } from './flow-store.mjs';
19 import { resolveFlowVisibleScopes } from './flow-scope.mjs';
20
21 export const FLOW_EXTERNAL_AGENT_POLICY_FILE = 'hub_flow_external_agent_policy.json';
22 export const FLOW_EXTERNAL_GRANTS_FILE = 'hub_flow_external_grants.json';
23 export const FLOW_EXTERNAL_GRANT_SCHEMA = 'knowtation.flow_external_grant/v0';
24 export const FLOW_EXTERNAL_GRANT_MINT_SCHEMA = 'knowtation.flow_external_grant_mint/v0';
25 export const GRANT_ID_PREFIX = 'fgrnt_';
26 export const GRANT_BEARER_PREFIX = 'fgrnt_bearer_';
27 export const DEFAULT_TTL_SECONDS = 3600;
28 export const MAX_TTL_SECONDS = 86400;
29 export const DEFAULT_MAX_INVOCATIONS = 100;
30
31 /** @typedef {import('./flow-scope.mjs').FlowScope} FlowScope */
32
33 /**
34 * @param {unknown} v
35 * @returns {boolean|null}
36 */
37 function envTriState(v) {
38 if (v === '1' || v === 'true') return true;
39 if (v === '0' || v === 'false') return false;
40 return null;
41 }
42
43 /**
44 * @param {string} dataDir
45 * @returns {object}
46 */
47 export function readFlowExternalAgentPolicyFile(dataDir) {
48 if (!dataDir) return {};
49 const fp = path.join(dataDir, FLOW_EXTERNAL_AGENT_POLICY_FILE);
50 try {
51 if (!fs.existsSync(fp)) return {};
52 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
53 return j && typeof j === 'object' ? j : {};
54 } catch {
55 return {};
56 }
57 }
58
59 /**
60 * @param {string} dataDir
61 * @returns {boolean}
62 */
63 export function getFlowExternalAgentEnabled(dataDir) {
64 const fromEnv = envTriState(process.env.FLOW_EXTERNAL_AGENT_ENABLED);
65 if (fromEnv !== null) return fromEnv;
66 const policy = readFlowExternalAgentPolicyFile(dataDir);
67 const ea = policy.external_agent;
68 if (ea && typeof ea === 'object' && typeof ea.enabled === 'boolean') {
69 return ea.enabled;
70 }
71 return false;
72 }
73
74 /**
75 * @param {string} dataDir
76 * @returns {boolean}
77 */
78 export function getFlowHostedProjectionEnabled(dataDir) {
79 const fromEnv = envTriState(process.env.FLOW_HOSTED_PROJECTION_ENABLED);
80 if (fromEnv !== null) return fromEnv;
81 const policy = readFlowExternalAgentPolicyFile(dataDir);
82 const ea = policy.external_agent;
83 if (ea && typeof ea === 'object' && typeof ea.hosted_projection_enabled === 'boolean') {
84 return ea.hosted_projection_enabled;
85 }
86 return false;
87 }
88
89 /**
90 * @param {string} dataDir
91 * @returns {boolean}
92 */
93 export function getFlowExternalAgentPolicyForbidden(dataDir) {
94 const fromEnv = envTriState(process.env.FLOW_EXTERNAL_AGENT_FORBIDDEN);
95 if (fromEnv !== null) return fromEnv;
96 const policy = readFlowExternalAgentPolicyFile(dataDir);
97 const ea = policy.external_agent;
98 if (ea && typeof ea === 'object' && typeof ea.forbidden === 'boolean') {
99 return ea.forbidden;
100 }
101 return false;
102 }
103
104 /**
105 * @param {string} dataDir
106 * @returns {{ allowedTools: Set<string>, defaultTtlSeconds: number, maxTtlSeconds: number, importPolicy: string }}
107 */
108 export function readVaultExternalAgentPolicy(dataDir) {
109 const policy = readFlowExternalAgentPolicyFile(dataDir);
110 const ea = policy.external_agent && typeof policy.external_agent === 'object' ? policy.external_agent : {};
111 const allowed = new Set();
112 if (Array.isArray(ea.allowed_tools)) {
113 for (const entry of ea.allowed_tools) {
114 if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.trim()) {
115 allowed.add(entry.id.trim());
116 } else if (typeof entry === 'string' && entry.trim()) {
117 allowed.add(entry.trim());
118 }
119 }
120 }
121 const defaultTtl =
122 typeof ea.default_ttl_seconds === 'number' && ea.default_ttl_seconds > 0
123 ? ea.default_ttl_seconds
124 : DEFAULT_TTL_SECONDS;
125 const maxTtl =
126 typeof ea.max_ttl_seconds === 'number' && ea.max_ttl_seconds > 0
127 ? ea.max_ttl_seconds
128 : MAX_TTL_SECONDS;
129 const importPolicy =
130 typeof ea.import_policy === 'string' && ea.import_policy.trim()
131 ? ea.import_policy.trim()
132 : 'reject';
133 return { allowedTools: allowed, defaultTtlSeconds: defaultTtl, maxTtlSeconds: maxTtl, importPolicy };
134 }
135
136 /**
137 * @param {object[]} steps
138 * @returns {Set<string>}
139 */
140 export function collectFlowExternalToolRefs(steps) {
141 const ids = new Set();
142 for (const step of steps) {
143 if (!Array.isArray(step?.skill_refs)) continue;
144 for (const ref of step.skill_refs) {
145 if (ref && ref.kind === 'external_tool' && typeof ref.id === 'string' && ref.id.trim()) {
146 ids.add(ref.id.trim());
147 }
148 }
149 }
150 return ids;
151 }
152
153 /**
154 * @param {Set<string>} flowToolRefs
155 * @param {Set<string>} vaultAllowlist
156 * @param {string[]} requested
157 * @returns {string[]}
158 */
159 export function intersectGrantTools(flowToolRefs, vaultAllowlist, requested) {
160 const out = [];
161 for (const id of requested) {
162 const trimmed = typeof id === 'string' ? id.trim() : '';
163 if (!trimmed) continue;
164 if (!flowToolRefs.has(trimmed)) continue;
165 if (!vaultAllowlist.has(trimmed)) continue;
166 out.push(trimmed);
167 }
168 return [...new Set(out)].sort();
169 }
170
171 /**
172 * @param {Set<string>} flowToolRefs
173 * @param {Set<string>} vaultAllowlist
174 * @returns {string[]}
175 */
176 export function computeBundleAllowedTools(flowToolRefs, vaultAllowlist) {
177 const out = [];
178 for (const id of flowToolRefs) {
179 if (vaultAllowlist.has(id)) out.push(id);
180 }
181 return out.sort();
182 }
183
184 /**
185 * @param {object[]} steps
186 * @param {Set<string>} vaultAllowlist
187 * @returns {{ ok: true } | { ok: false, denied: string[] }}
188 */
189 export function validateImportExternalTools(steps, vaultAllowlist) {
190 const refs = collectFlowExternalToolRefs(steps);
191 const denied = [];
192 for (const id of refs) {
193 if (!vaultAllowlist.has(id)) denied.push(id);
194 }
195 if (denied.length > 0) return { ok: false, denied };
196 return { ok: true };
197 }
198
199 /**
200 * @param {string} dataDir
201 * @returns {string}
202 */
203 function grantsFilePath(dataDir) {
204 return path.join(dataDir, FLOW_EXTERNAL_GRANTS_FILE);
205 }
206
207 /**
208 * @param {string} dataDir
209 * @returns {{ vaults: Record<string, { grants: object[] }> }}
210 */
211 export function loadExternalGrantsStore(dataDir) {
212 const fp = grantsFilePath(dataDir);
213 if (!fs.existsSync(fp)) return { vaults: {} };
214 try {
215 const j = JSON.parse(fs.readFileSync(fp, 'utf8'));
216 if (!j || typeof j !== 'object') return { vaults: {} };
217 return { vaults: j.vaults && typeof j.vaults === 'object' ? j.vaults : {} };
218 } catch {
219 return { vaults: {} };
220 }
221 }
222
223 /**
224 * @param {string} dataDir
225 * @param {{ vaults: Record<string, { grants: object[] }> }} store
226 */
227 export function saveExternalGrantsStore(dataDir, store) {
228 const fp = grantsFilePath(dataDir);
229 fs.mkdirSync(path.dirname(fp), { recursive: true });
230 fs.writeFileSync(fp, JSON.stringify(store, null, 2), 'utf8');
231 }
232
233 /**
234 * @param {string} bearer
235 * @returns {string}
236 */
237 export function hashGrantBearer(bearer) {
238 return createHash('sha256').update(bearer, 'utf8').digest('hex');
239 }
240
241 /**
242 * @param {string} actorLabel
243 * @param {string} vaultId
244 * @param {string} issuer
245 * @returns {string}
246 */
247 export function hashActorLabel(actorLabel, vaultId, issuer) {
248 const payload = `${actorLabel}|${vaultId}|${issuer}`;
249 return createHash('sha256').update(payload, 'utf8').digest('hex');
250 }
251
252 /**
253 * @returns {string}
254 */
255 function randomToken(prefix, byteLen = 16) {
256 return prefix + randomBytes(byteLen).toString('base64url').replace(/[^a-z0-9_]/gi, '_').slice(0, 24);
257 }
258
259 /**
260 * Strip internal fields from a stored grant for client responses.
261 *
262 * @param {object} stored
263 * @returns {object}
264 */
265 export function grantForClient(stored) {
266 const {
267 grant_bearer_hash: _b,
268 ...grant
269 } = stored;
270 return grant;
271 }
272
273 /**
274 * @param {{
275 * dataDir: string,
276 * vaultId: string,
277 * flowId: string,
278 * flowVersion: string,
279 * requestedTools: string[],
280 * ttlSeconds?: number,
281 * actorLabel?: string,
282 * issuer?: string,
283 * }} input
284 * @returns {{ grant: object, bearer: string, expires_at: string }}
285 */
286 export function mintExternalGrantRecord(input) {
287 const vaultPolicy = readVaultExternalAgentPolicy(input.dataDir);
288 const ttlRequested =
289 typeof input.ttlSeconds === 'number' && input.ttlSeconds > 0
290 ? Math.min(input.ttlSeconds, vaultPolicy.maxTtlSeconds)
291 : vaultPolicy.defaultTtlSeconds;
292 const ttl = Math.min(ttlRequested, vaultPolicy.maxTtlSeconds);
293 const now = new Date();
294 const expiresAt = new Date(now.getTime() + ttl * 1000).toISOString();
295 const grantId = randomToken(GRANT_ID_PREFIX);
296 const bearer = randomToken(GRANT_BEARER_PREFIX, 24);
297 const actorLabel = typeof input.actorLabel === 'string' ? input.actorLabel.trim() : '';
298 const issuer = typeof input.issuer === 'string' ? input.issuer.trim() : '';
299 const grant = {
300 schema: FLOW_EXTERNAL_GRANT_SCHEMA,
301 grant_id: grantId,
302 vault_id: input.vaultId,
303 scope: input.scope,
304 flow_id: input.flowId,
305 flow_version: input.flowVersion,
306 allowed_tools: input.requestedTools,
307 allowed_harnesses: ['agent_bundle'],
308 expires_at: expiresAt,
309 issued_at: now.toISOString(),
310 revoked_at: null,
311 actor_hash: hashActorLabel(actorLabel, input.vaultId, issuer),
312 max_invocations: DEFAULT_MAX_INVOCATIONS,
313 invocation_count: 0,
314 };
315 return {
316 grant,
317 bearer,
318 expires_at: expiresAt,
319 grant_bearer_hash: hashGrantBearer(bearer),
320 };
321 }
322
323 /**
324 * @param {object} ctx
325 * @returns {{ ok: false, status: number, error: string, code: string }}
326 */
327 function refuse(status, code, error) {
328 return { ok: false, status, error, code };
329 }
330
331 /**
332 * @param {{
333 * dataDir: string,
334 * vaultId: string,
335 * flowId: string,
336 * flowVersion: string,
337 * requestedTools: unknown,
338 * ttlSeconds?: number,
339 * actorLabel?: string,
340 * userId?: string,
341 * role?: string,
342 * cliScopes?: FlowScope[],
343 * visibleScopes?: Set<FlowScope>,
344 * ambiguous?: boolean,
345 * starterDir?: string,
346 * }} input
347 */
348 export function handleFlowExternalGrantMintRequest(input) {
349 if (getFlowExternalAgentPolicyForbidden(input.dataDir)) {
350 return refuse(403, 'FLOW_EXTERNAL_AGENT_POLICY_FORBIDDEN', 'External agent forbidden by policy');
351 }
352 if (!getFlowExternalAgentEnabled(input.dataDir)) {
353 return refuse(403, 'FLOW_EXTERNAL_AGENT_DISABLED', 'External agent gate is disabled');
354 }
355
356 const flowId = typeof input.flowId === 'string' ? input.flowId.trim() : '';
357 const flowVersion = typeof input.flowVersion === 'string' ? input.flowVersion.trim() : '';
358 if (!flowId || !flowVersion) {
359 return refuse(400, 'BAD_REQUEST', 'flow_id and flow_version are required');
360 }
361
362 const requestedRaw = input.requestedTools;
363 if (!Array.isArray(requestedRaw) || requestedRaw.length === 0) {
364 return refuse(400, 'BAD_REQUEST', 'requested_tools must be a non-empty array');
365 }
366 const requestedTools = requestedRaw.map((t) => (typeof t === 'string' ? t.trim() : '')).filter(Boolean);
367 if (requestedTools.length === 0) {
368 return refuse(400, 'BAD_REQUEST', 'requested_tools must be a non-empty array');
369 }
370
371 const resolved =
372 input.visibleScopes instanceof Set
373 ? { visibleScopes: input.visibleScopes, ambiguous: false }
374 : input.ambiguous === true
375 ? { visibleScopes: new Set(['personal']), ambiguous: true }
376 : resolveFlowVisibleScopes({
377 dataDir: input.dataDir,
378 userId: input.userId,
379 vaultId: input.vaultId,
380 role: input.role,
381 cliScopes: input.cliScopes,
382 });
383 if (resolved.ambiguous) {
384 return refuse(400, 'FLOW_SCOPE_AMBIGUOUS', 'Ambiguous flow scope');
385 }
386
387 const pinnedPayload = getFlow(input.dataDir, input.vaultId, flowId, {
388 filterScopes: resolved.visibleScopes,
389 version: flowVersion,
390 starterDir: input.starterDir,
391 });
392 if (!pinnedPayload) {
393 return refuse(404, 'unknown_flow', 'unknown_flow');
394 }
395
396 const flowToolRefs = collectFlowExternalToolRefs(pinnedPayload.steps);
397 for (const tool of requestedTools) {
398 if (!flowToolRefs.has(tool)) {
399 return refuse(400, 'FLOW_EXTERNAL_TOOL_UNKNOWN', 'Tool not declared on flow steps');
400 }
401 }
402
403 const vaultPolicy = readVaultExternalAgentPolicy(input.dataDir);
404 for (const tool of requestedTools) {
405 if (!vaultPolicy.allowedTools.has(tool)) {
406 return refuse(403, 'FLOW_EXTERNAL_TOOL_DENIED', 'Tool not in vault allowlist');
407 }
408 }
409
410 const allowed = intersectGrantTools(flowToolRefs, vaultPolicy.allowedTools, requestedTools);
411 if (allowed.length === 0) {
412 return refuse(403, 'FLOW_EXTERNAL_GRANT_DENIED', 'Grant denied');
413 }
414
415 const minted = mintExternalGrantRecord({
416 dataDir: input.dataDir,
417 vaultId: input.vaultId,
418 flowId,
419 flowVersion,
420 requestedTools: allowed,
421 scope: pinnedPayload.flow.scope,
422 ttlSeconds: input.ttlSeconds,
423 actorLabel: input.actorLabel,
424 issuer: input.userId ?? '',
425 });
426
427 const store = loadExternalGrantsStore(input.dataDir);
428 if (!store.vaults[input.vaultId]) store.vaults[input.vaultId] = { grants: [] };
429 store.vaults[input.vaultId].grants.push({
430 ...minted.grant,
431 grant_bearer_hash: minted.grant_bearer_hash,
432 });
433 saveExternalGrantsStore(input.dataDir, store);
434
435 return {
436 ok: true,
437 payload: {
438 schema: FLOW_EXTERNAL_GRANT_MINT_SCHEMA,
439 grant: grantForClient(minted.grant),
440 bearer: minted.bearer,
441 expires_at: minted.expires_at,
442 },
443 };
444 }
445
446 /**
447 * @param {{
448 * dataDir: string,
449 * vaultId: string,
450 * grantId: string,
451 * }} input
452 */
453 export function handleFlowExternalGrantRevokeRequest(input) {
454 if (!getFlowExternalAgentEnabled(input.dataDir)) {
455 return refuse(403, 'FLOW_EXTERNAL_AGENT_DISABLED', 'External agent gate is disabled');
456 }
457
458 const grantId = typeof input.grantId === 'string' ? input.grantId.trim() : '';
459 if (!grantId) {
460 return refuse(400, 'BAD_REQUEST', 'grant_id is required');
461 }
462
463 const store = loadExternalGrantsStore(input.dataDir);
464 const vault = store.vaults[input.vaultId];
465 if (!vault || !Array.isArray(vault.grants)) {
466 return refuse(404, 'BAD_REQUEST', 'Grant not found');
467 }
468
469 const idx = vault.grants.findIndex((g) => g.grant_id === grantId);
470 if (idx < 0) {
471 return refuse(404, 'BAD_REQUEST', 'Grant not found');
472 }
473
474 vault.grants[idx] = {
475 ...vault.grants[idx],
476 revoked_at: new Date().toISOString(),
477 };
478 saveExternalGrantsStore(input.dataDir, store);
479
480 return { ok: true, payload: grantForClient(vault.grants[idx]) };
481 }
482
483 /**
484 * @param {{
485 * dataDir: string,
486 * vaultId: string,
487 * flowId?: string,
488 * }} input
489 */
490 export function handleFlowExternalGrantListRequest(input) {
491 if (!getFlowExternalAgentEnabled(input.dataDir)) {
492 return refuse(403, 'FLOW_EXTERNAL_AGENT_DISABLED', 'External agent gate is disabled');
493 }
494
495 const store = loadExternalGrantsStore(input.dataDir);
496 const vault = store.vaults[input.vaultId];
497 const grants = vault && Array.isArray(vault.grants) ? vault.grants : [];
498 const flowFilter = typeof input.flowId === 'string' ? input.flowId.trim() : '';
499
500 const filtered = flowFilter
501 ? grants.filter((g) => g.flow_id === flowFilter)
502 : grants;
503
504 return {
505 ok: true,
506 payload: {
507 schema: 'knowtation.flow_external_grant_list/v0',
508 vault_id: input.vaultId,
509 grants: filtered.map(grantForClient),
510 },
511 };
512 }
513
514 /**
515 * @param {{
516 * dataDir: string,
517 * vaultId: string,
518 * bearer: string,
519 * flowId?: string,
520 * flowVersion?: string,
521 * toolId?: string,
522 * }} input
523 * @returns {{ ok: true, grant: object } | { ok: false, status: number, code: string }}
524 */
525 export function validateExternalGrantBearer(input) {
526 if (!getFlowExternalAgentEnabled(input.dataDir)) {
527 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_AGENT_DISABLED' };
528 }
529
530 const bearer = typeof input.bearer === 'string' ? input.bearer.trim() : '';
531 if (!bearer.startsWith(GRANT_BEARER_PREFIX)) {
532 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_DENIED' };
533 }
534
535 const hash = hashGrantBearer(bearer);
536 const store = loadExternalGrantsStore(input.dataDir);
537 const vault = store.vaults[input.vaultId];
538 if (!vault || !Array.isArray(vault.grants)) {
539 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_DENIED' };
540 }
541
542 const grant = vault.grants.find((g) => g.grant_bearer_hash === hash);
543 if (!grant) {
544 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_DENIED' };
545 }
546
547 if (grant.revoked_at) {
548 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_REVOKED' };
549 }
550
551 const now = Date.now();
552 const expires = Date.parse(grant.expires_at);
553 if (!Number.isFinite(expires) || now > expires) {
554 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_EXPIRED' };
555 }
556
557 if (input.flowId && grant.flow_id !== input.flowId.trim()) {
558 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_FLOW_MISMATCH' };
559 }
560
561 if (input.flowVersion && grant.flow_version !== input.flowVersion.trim()) {
562 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_FLOW_MISMATCH' };
563 }
564
565 if (input.toolId) {
566 const tool = input.toolId.trim();
567 if (!Array.isArray(grant.allowed_tools) || !grant.allowed_tools.includes(tool)) {
568 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_GRANT_TOOL_DENIED' };
569 }
570 const vaultPolicy = readVaultExternalAgentPolicy(input.dataDir);
571 if (!vaultPolicy.allowedTools.has(tool)) {
572 return { ok: false, status: 403, code: 'FLOW_EXTERNAL_TOOL_DENIED' };
573 }
574 }
575
576 return { ok: true, grant: grantForClient(grant) };
577 }
578
579 /**
580 * Invoke stub — validates bearer; does not call real tool providers in v0.
581 *
582 * @param {{
583 * dataDir: string,
584 * vaultId: string,
585 * toolId: string,
586 * bearer: string,
587 * flowId?: string,
588 * flowVersion?: string,
589 * }} input
590 */
591 export function handleFlowExternalToolInvokeRequest(input) {
592 const toolId = typeof input.toolId === 'string' ? input.toolId.trim() : '';
593 if (!toolId) {
594 return refuse(400, 'BAD_REQUEST', 'tool_id is required');
595 }
596
597 const validation = validateExternalGrantBearer({
598 dataDir: input.dataDir,
599 vaultId: input.vaultId,
600 bearer: input.bearer,
601 flowId: input.flowId,
602 flowVersion: input.flowVersion,
603 toolId,
604 });
605
606 if (!validation.ok) {
607 return refuse(validation.status, validation.code, validation.code);
608 }
609
610 const store = loadExternalGrantsStore(input.dataDir);
611 const vault = store.vaults[input.vaultId];
612 const stored = vault?.grants?.find((g) => g.grant_id === validation.grant.grant_id);
613 if (stored) {
614 stored.invocation_count = (stored.invocation_count ?? 0) + 1;
615 saveExternalGrantsStore(input.dataDir, store);
616 }
617
618 return {
619 ok: true,
620 payload: {
621 schema: 'knowtation.flow_external_tool_invoke/v0',
622 tool_id: toolId,
623 accepted: true,
624 grant_id: validation.grant.grant_id,
625 },
626 };
627 }
628
629 /**
630 * Whether `agent_bundle` harness is renderable (gate on).
631 *
632 * @param {string} dataDir
633 * @returns {boolean}
634 */
635 export function isAgentBundleHarnessActive(dataDir) {
636 return getFlowExternalAgentEnabled(dataDir);
637 }
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 14 hours ago