external-agent.mjs
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