diff --git a/docs/index.html b/docs/index.html index 8a06e30..78ebfdd 100755 --- a/docs/index.html +++ b/docs/index.html @@ -25169,6 +25169,19 @@ Be specific and actionable. Format as structured JSON.`; throw new Error('Approval required for ' + tgt + ' — choose Autonomous mode, or approve in the receipts panel.'); } } + if (res.status === 403 && Array.isArray(d.approvals) && d.approvals.length) { + const approvalIds = d.approvals.map(function(a){ return a && a.id; }).filter(Boolean); + const targets = (d.outOfScopeTargets || []).join(', '); + if (isLab || autonomous) { + await Promise.all(approvalIds.map(function(id){ + return fetch('/api/approvals/' + id + '/approve', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' }); + })); + res = await fetch('/api/admiral/launch', launchOpts({ approvalIds: approvalIds })); + d = await res.json(); + } else { + throw new Error('Expanded target approval required for ' + (targets || 'planner-added targets') + ' — approve the receipts or choose Autonomous mode.'); + } + } if (res.status === 409 && d.review && d.review.status === 'hold') throw new Error('Op Admiral plan gate is HOLD — ' + ((d.review && d.review.reason) || 'manual review required before execution')); if (!res.ok || d.error) throw new Error(d.error || ('launch failed (' + res.status + ')')); const nOps = (d.operators || []).length; diff --git a/src/__tests__/local-api-hardening-static.test.ts b/src/__tests__/local-api-hardening-static.test.ts index 3e14770..4749899 100644 --- a/src/__tests__/local-api-hardening-static.test.ts +++ b/src/__tests__/local-api-hardening-static.test.ts @@ -33,9 +33,10 @@ describe('local API authorization hardening invariants', () => { it('Admiral live launch re-checks every General-produced execution target before mission bring-up', () => { const route = routeBlock("app.post('/api/admiral/launch'", '// =============================================================================\n// BOUNTY PLATFORM INTEGRATIONS'); - expect(route).toMatch(/ensureExecTargetsWithinApprovedTarget\(execConfig\.targets, brief\.target\)/); + expect(route).toMatch(/ensureExecTargetsAuthorized\(execConfig\.targets,\s*brief\.target,\s*req\.body/); expect(route).toMatch(/outOfScopeTargets\.length/); - expect(route.indexOf('ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target)')) + expect(route.indexOf('ensureExecTargetsAuthorized(execConfig.targets, brief.target')) .toBeLessThan(route.indexOf('bringUpMissionFromPlan(execConfig, generalConfig)')); + expect(route).toMatch(/approvals/); }); }); diff --git a/src/server.ts b/src/server.ts index ac94b07..6a185b7 100755 --- a/src/server.ts +++ b/src/server.ts @@ -1104,6 +1104,15 @@ function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget .filter(target => hostFromTarget(target) !== approvedHost); } +function ensureExecTargetsAuthorized( + targets: string[], + approvedTarget: string, + body: Record, +): string[] { + return ensureExecTargetsWithinApprovedTarget(targets, approvedTarget) + .filter(target => !findApproval(body, 'autonomous_execution', target)); +} + function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean { if (!approvalMatches(approval, action, target)) return false; if (approval.operationId && operationId && approval.operationId !== operationId) return false; @@ -7326,12 +7335,20 @@ app.post('/api/admiral/launch', async (req: Request, res: Response): Promise); if (outOfScopeTargets.length) { + const approvals = outOfScopeTargets.map(target => createApprovalRequest( + 'autonomous_execution', + target, + `Admiral plan expanded execution scope from ${brief.target} to ${target}`, + req.body as Record + )); res.status(403).json({ - error: 'Admiral LIVE plan contains targets outside the approved brief target', + error: 'Admiral LIVE plan needs approval for expanded target scope', approvedTarget: brief.target, outOfScopeTargets, + approvals, + next: 'Approve the expanded target receipts, then retry /api/admiral/launch with approvalIds.', }); return; }