diff --git a/package.json b/package.json index 8d2b07d..0ea5957 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "demo:setup": "node scripts/demo-setup.js", "demo": "node examples/awf-demo/src/run-demo.js", "demo:cross-runtime": "cat examples/cross-runtime/terminal-demo.md", + "authorize:blocked": "node tools/authorize-task/authorize-task.js --task-class db_migration --risk-lane high --runtime codex --workspace awf-demo", + "authorize:authorized": "node tools/authorize-task/authorize-task.js --task-class ui_refactor --risk-lane low --runtime cursor --workspace awf-demo", + "authorize:supervised": "node tools/authorize-task/authorize-task.js --task-class api_change --risk-lane high --runtime claude_code --workspace awf-demo", "test": "npm run test --workspaces --if-present" }, "dependencies": { diff --git a/tools/authorize-task/authorize-task.js b/tools/authorize-task/authorize-task.js new file mode 100644 index 0000000..6d923b0 --- /dev/null +++ b/tools/authorize-task/authorize-task.js @@ -0,0 +1,297 @@ +#!/usr/bin/env node +// AWF Runtime Authorization — Sprint 3 demo CLI. +// +// Simulates the Trust-Based Runtime Authorization algorithm from +// AWF Sprint Plan v4.4.2. Hard-codes a small library of task risk +// profiles and per-runtime trust capability profiles, then prints +// the AUTHORIZED | SUPERVISED | BLOCKED decision with the same +// fields the production decision record carries: +// +// - task risk profile (5 dimensions, composite, risk lane) +// - trust capability profile (tier, sessions, evidence strength) +// - decision + required vs current tier +// - reason text and D1-D4 evidence summary +// - required controls +// - recommended alternative runtime (when blocked) +// - audit event type the auth service would emit +// +// Usage: +// node tools/authorize-task/authorize-task.js \ +// --task-class db_migration \ +// --risk-lane high \ +// --runtime codex \ +// --workspace awf-demo + +const TIER_ORDER = ['PROVISIONAL', 'RESTRICTED', 'STANDARD', 'HIGH']; + +// Per-task-class risk dimensions (each 1-5). Composite = sum. +const TASK_RISK_PROFILES = { + ui_refactor: { complexity: 1, blast: 2, security: 1, concurrency: 1, business: 2 }, + api_change: { complexity: 4, blast: 4, security: 3, concurrency: 3, business: 3 }, + db_migration: { complexity: 4, blast: 5, security: 3, concurrency: 3, business: 5 }, + security_fix: { complexity: 3, blast: 3, security: 5, concurrency: 2, business: 4 } +}; + +// Per-runtime trust capability, keyed by task_class. +// Profiles with no D1-D4 fields render as "no history" (E0, n=0). +const TRUST_PROFILES = { + cursor: { + ui_refactor: { + tier: 'HIGH', sessions: 47, evidence: 'E3', + d1: '92 (n=47)', d2: '88', d3: '100 (zero violations)', d4: '100 (no recurrence)' + }, + api_change: { + tier: 'STANDARD', sessions: 12, evidence: 'E2', + d1: '78 (n=12)', d2: '70', d3: '95', d4: '90' + }, + db_migration: { tier: 'PROVISIONAL', sessions: 0, evidence: 'E0' }, + security_fix: { tier: 'PROVISIONAL', sessions: 0, evidence: 'E0' } + }, + codex: { + ui_refactor: { + tier: 'STANDARD', sessions: 18, evidence: 'E2', + d1: '81 (n=18)', d2: '72', d3: '98', d4: '94' + }, + api_change: { + tier: 'RESTRICTED', sessions: 4, evidence: 'E1', + d1: '74 (n=4)', d2: '60', d3: '95', d4: 'insufficient (n<5)' + }, + db_migration: { tier: 'PROVISIONAL', sessions: 0, evidence: 'E0' }, + security_fix: { tier: 'PROVISIONAL', sessions: 0, evidence: 'E0' } + }, + claude_code: { + ui_refactor: { + tier: 'HIGH', sessions: 62, evidence: 'E3', + d1: '94 (n=62)', d2: '90', d3: '100', d4: '100' + }, + api_change: { + tier: 'STANDARD', sessions: 22, evidence: 'E3', + d1: '86 (n=22)', d2: '81', d3: '98', d4: '95', + recent_failure: 'breaking_change_without_version_bump' + }, + db_migration: { + tier: 'STANDARD', sessions: 14, evidence: 'E3', + d1: '82 (n=14)', d2: '78', d3: '100', d4: '95' + }, + security_fix: { + tier: 'STANDARD', sessions: 15, evidence: 'E3', + d1: '85 (n=15)', d2: '80', d3: '100', d4: '95' + } + } +}; + +// (task_class, lane) → controls that must accompany a SUPERVISED +// decision, or that would have been required for a BLOCKED one. +const CONTROLS = { + db_migration: { + critical: [ + 'Human pre-approval required', + 'Migration plan review', + 'Schema validation', + 'Rollback plan attached' + ] + }, + api_change: { + high: [ + 'pre-tool-call hook on schema files', + 'tech-lead review before PR', + 'contract tests must pass' + ] + } +}; + +// Display-only names for runtime (sentence-start in reason text) +// and task-class plural (for the BLOCKED critical reason sentence). +const RUNTIME_DISPLAY = { + cursor: 'Cursor', + codex: 'Codex', + claude_code: 'Claude Code' +}; +const TASK_DISPLAY_PLURAL = { + ui_refactor: 'UI refactors', + api_change: 'API changes', + db_migration: 'database migrations', + security_fix: 'security fixes' +}; + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + args[argv[i].slice(2)] = argv[i + 1]; + i++; + } + } + return args; +} + +function laneFor(composite) { + if (composite <= 9) return 'low'; + if (composite <= 14) return 'medium'; + if (composite <= 19) return 'high'; + return 'critical'; +} + +function laneLabel(lane) { + return { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' }[lane]; +} + +function requiredTierFor(lane) { + return { low: 'RESTRICTED', medium: 'STANDARD', high: 'STANDARD', critical: 'HIGH' }[lane]; +} + +const tierIdx = t => TIER_ORDER.indexOf(t); + +function decide(lane, currentTier, requiredTier) { + // Critical lane requires human approval, which runtime auth alone cannot grant. + if (lane === 'critical') return 'BLOCKED'; + if (tierIdx(currentTier) < tierIdx(requiredTier)) return 'BLOCKED'; + if (lane === 'high' && tierIdx(currentTier) < tierIdx('HIGH')) return 'SUPERVISED'; + return 'AUTHORIZED'; +} + +function recommendRuntime(taskClass, excluding) { + let best = null, bestIdx = -1; + for (const [runtime, profiles] of Object.entries(TRUST_PROFILES)) { + if (runtime === excluding) continue; + const p = profiles[taskClass]; + if (!p) continue; + const idx = tierIdx(p.tier); + if (idx > bestIdx) { bestIdx = idx; best = { runtime, tier: p.tier }; } + } + return best; +} + +function auditEvent(decision) { + return { + AUTHORIZED: 'RUNTIME_AUTHORIZATION_GRANTED', + SUPERVISED: 'RUNTIME_AUTHORIZATION_GRANTED_WITH_CONTROLS', + BLOCKED: 'RUNTIME_AUTHORIZATION_BLOCKED' + }[decision]; +} + +function reasonLines({ decision, runtime, taskClass, lane, currentTier, requiredTier }) { + const name = RUNTIME_DISPLAY[runtime] ?? runtime; + if (decision === 'BLOCKED') { + if (lane === 'critical') { + return [ + `${name} has not earned sufficient trust for ${laneLabel(lane)} lane`, + `${TASK_DISPLAY_PLURAL[taskClass] ?? taskClass} in this workspace.` + ]; + } + return [ + `${name} holds ${currentTier} tier for ${taskClass},`, + `below the ${requiredTier} requirement for ${laneLabel(lane)} lane execution.` + ]; + } + if (decision === 'SUPERVISED') { + return [ + `${name} holds ${currentTier} tier for ${taskClass}, sufficient`, + `for ${laneLabel(lane)} lane execution under control wrappers.` + ]; + } + return [ + `${name} holds ${currentTier} tier for ${taskClass}, exceeding`, + `the ${requiredTier} requirement for ${laneLabel(lane)} lane execution.` + ]; +} + +// Column alignment helpers. Values land at column 28 in all sections. +const head = (label, value) => `${label.padEnd(16)}${value}`; +const row = (label, value) => ` ${label.padEnd(26)}${value}`; +const ev = (label, value) => ` ${label.padEnd(24)}${value}`; +const DIV = '─────────────────────────────────────────────'; + +function main() { + const args = parseArgs(process.argv.slice(2)); + const taskClass = args['task-class']; + const riskLaneArg = args['risk-lane']; + const runtime = args.runtime; + const workspace = args.workspace; + + const risk = TASK_RISK_PROFILES[taskClass]; + if (!risk) { console.error(`unknown task class: ${taskClass}`); process.exit(1); } + const trust = TRUST_PROFILES[runtime]?.[taskClass]; + if (!trust) { console.error(`unknown runtime/task pair: ${runtime}/${taskClass}`); process.exit(1); } + + const composite = risk.complexity + risk.blast + risk.security + + risk.concurrency + risk.business; + const lane = laneFor(composite); + const requiredTier = requiredTierFor(lane); + const decision = decide(lane, trust.tier, requiredTier); + + const out = []; + out.push('AWF Runtime Authorization'); + out.push(DIV); + out.push(head('Task class:', taskClass)); + out.push(head('Risk lane:', riskLaneArg)); + out.push(head('Candidate:', runtime)); + out.push(head('Workspace:', workspace)); + out.push(''); + out.push('Task Risk Profile:'); + out.push(row('Code complexity:', risk.complexity)); + out.push(row('Blast radius:', risk.blast)); + out.push(row('Security sensitivity:', risk.security)); + out.push(row('Concurrency risk:', risk.concurrency)); + out.push(row('Business criticality:', risk.business)); + out.push(row('Composite score:', `${composite} → ${laneLabel(lane)} lane`)); + out.push(''); + out.push('Trust Capability Profile:'); + out.push(row('Runtime:', runtime)); + out.push(row('Task class:', taskClass)); + out.push(row('Current trust tier:', trust.tier)); + out.push(row('Sessions:', trust.sessions)); + out.push(row('Evidence strength:', trust.evidence)); + out.push(''); + out.push('Authorization Decision:'); + out.push(row('Decision:', decision)); + out.push(row('Required trust tier:', + decision === 'SUPERVISED' ? `${requiredTier} + controls` : requiredTier)); + out.push(row('Current trust tier:', trust.tier)); + out.push(' '); + out.push(' Reason:'); + reasonLines({ decision, runtime, taskClass, lane, currentTier: trust.tier, requiredTier }) + .forEach(l => out.push(` ${l}`)); + out.push(' '); + out.push(' Evidence:'); + out.push(ev('D1 Correctness:', trust.d1 ?? 'no history')); + out.push(ev('D2 Observability:', trust.d2 ?? 'no history')); + out.push(ev('D3 Policy:', trust.d3 ?? 'no history')); + out.push(ev('D4 Recurrence:', trust.d4 ?? 'no history')); + if (trust.recent_failure) { + out.push(''); + out.push(row('Recent failure pattern:', trust.recent_failure)); + } + out.push(''); + + const controls = CONTROLS[taskClass]?.[lane]; + if (decision === 'BLOCKED') { + if (controls) { + out.push(' Required controls:'); + controls.forEach(c => out.push(` - ${c}`)); + out.push(''); + } + const rec = recommendRuntime(taskClass, runtime); + if (rec) { + out.push(row('Recommended runtime:', rec.runtime)); + out.push(` Note: ${rec.runtime} holds ${rec.tier} tier for ${taskClass}`); + out.push(` but ${laneLabel(lane)} lane requires HIGH. No runtime is authorized`); + out.push(` for ${laneLabel(lane)} lane without human approval regardless of tier.`); + out.push(''); + } + } else if (decision === 'SUPERVISED' && controls) { + out.push(' Required controls:'); + controls.forEach(c => out.push(` - ${c}`)); + out.push(''); + } else if (decision === 'AUTHORIZED') { + out.push(' Required controls:'); + out.push(' None beyond standard merge approval'); + out.push(''); + } + + out.push(row('Audit event:', auditEvent(decision))); + out.push(DIV); + console.log(out.join('\n')); +} + +main();