Class: KairosMcp::Daemon::ApprovalGate
- Inherits:
-
Object
- Object
- KairosMcp::Daemon::ApprovalGate
- Defined in:
- lib/kairos_mcp/daemon/approval_gate.rb
Overview
ApprovalGate — pending-file based approval system for code-gen proposals.
Design (P3.2 v0.2 §4):
Proposals are staged as JSON files in .kairos/run/proposals/.
Human approval/rejection is recorded as a separate .decision.json file.
proposal_hash binds reviewed content to applied content cryptographically.
Defined Under Namespace
Classes: ApprovalGrant, BackpressureError, ConflictError, ExpiredError, NotFoundError
Constant Summary collapse
- DEFAULT_TTL =
8 hours (daemon mode)
28_800- MAX_PENDING =
16
Instance Method Summary collapse
-
#auto_approve(proposal) ⇒ Object
Auto-approve (fast path for L2 scopes).
-
#consume_grant(proposal_id) ⇒ Object
For cycle re-entry: returns ApprovalGrant or nil.
-
#initialize(dir:, clock: -> { Time.now.utc }, logger: nil) ⇒ ApprovalGate
constructor
A new instance of ApprovalGate.
-
#pending_proposals ⇒ Object
List pending proposals.
-
#read_decision(proposal_id) ⇒ Object
Read a decision record.
-
#read_proposal(proposal_id) ⇒ Object
Read a proposal record.
-
#record_decision(proposal_id, decision:, reviewer:, reason: nil) ⇒ Object
Record a human decision (via AttachServer mailbox).
-
#stage(proposal) ⇒ Object
Stage a pending-approval proposal.
-
#status_of(proposal_id) ⇒ Symbol
Non-blocking status check.
-
#verify_proposal_integrity(proposal_id) ⇒ Boolean
Verify proposal content integrity at apply time.
Constructor Details
#initialize(dir:, clock: -> { Time.now.utc }, logger: nil) ⇒ ApprovalGate
Returns a new instance of ApprovalGate.
21 22 23 24 25 26 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 21 def initialize(dir:, clock: -> { Time.now.utc }, logger: nil) @dir = dir @clock = clock @logger = logger FileUtils.mkdir_p(@dir, mode: 0o700) end |
Instance Method Details
#auto_approve(proposal) ⇒ Object
Auto-approve (fast path for L2 scopes).
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 52 def auto_approve(proposal) id = proposal[:proposal_id] || proposal['proposal_id'] raise ArgumentError, 'proposal_id required' if id.to_s.empty? now = @clock.call ttl = proposal[:ttl_seconds] || proposal['ttl_seconds'] || DEFAULT_TTL canonical = proposal.reject { |k, _| mutable_key?(k) } p_hash = canonical_hash(canonical) p = proposal.merge( status: 'auto_approved', proposal_hash: p_hash, created_at: now.iso8601, expires_at: (now + ttl).iso8601 ) write_atomic(file_for(id), JSON.pretty_generate(stringify_keys(p))) write_decision(id, decision: 'approve', reviewer: 'policy:auto_approve', proposal_hash: p_hash, granted_at: now.iso8601, reason: "scope=#{proposal.dig(:scope, :scope) || proposal.dig('scope', 'scope')} auto-approved") p end |
#consume_grant(proposal_id) ⇒ Object
For cycle re-entry: returns ApprovalGrant or nil. Never blocks.
106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 106 def consume_grant(proposal_id) case status_of(proposal_id) when :approved ApprovalGrant.new( proposal_id: proposal_id, decision: read_decision(proposal_id), proposal: read_proposal(proposal_id) ) else nil end end |
#pending_proposals ⇒ Object
List pending proposals.
133 134 135 136 137 138 139 140 141 142 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 133 def pending_proposals Dir.glob(File.join(@dir, '*.json')).filter_map do |f| next if f.end_with?('.decision.json') || f.end_with?('.applied.json') p = safe_read_json(f) next unless p next if p['status'] == 'auto_approved' && File.exist?(decision_file(p['proposal_id'])) s = status_of(p['proposal_id']) s == :pending ? p : nil end end |
#read_decision(proposal_id) ⇒ Object
Read a decision record.
150 151 152 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 150 def read_decision(proposal_id) safe_read_json(decision_file(proposal_id)) end |
#read_proposal(proposal_id) ⇒ Object
Read a proposal record.
145 146 147 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 145 def read_proposal(proposal_id) safe_read_json(file_for(proposal_id)) end |
#record_decision(proposal_id, decision:, reviewer:, reason: nil) ⇒ Object
Record a human decision (via AttachServer mailbox).
90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 90 def record_decision(proposal_id, decision:, reviewer:, reason: nil) raise ArgumentError, 'decision must be approve|reject' unless %w[approve reject].include?(decision) p = read_proposal(proposal_id) raise NotFoundError, "proposal not found: #{proposal_id}" unless p raise ConflictError, "already decided: #{proposal_id}" if File.exist?(decision_file(proposal_id)) raise ExpiredError, "expired: #{proposal_id}" if Time.parse(p['expires_at']) < @clock.call write_decision(proposal_id, decision: decision, reviewer: reviewer, proposal_hash: p['proposal_hash'], granted_at: @clock.call.iso8601, reason: reason) end |
#stage(proposal) ⇒ Object
Stage a pending-approval proposal. Returns the stored Hash.
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 29 def stage(proposal) id = proposal[:proposal_id] || proposal['proposal_id'] raise ArgumentError, 'proposal_id required' if id.to_s.empty? check_backpressure! now = @clock.call ttl = proposal[:ttl_seconds] || proposal['ttl_seconds'] || DEFAULT_TTL # Compute proposal_hash from canonical content (excludes mutable fields) canonical = proposal.reject { |k, _| mutable_key?(k) } p_hash = canonical_hash(canonical) p = proposal.merge( status: 'pending_approval', proposal_hash: p_hash, created_at: now.iso8601, expires_at: (now + ttl).iso8601 ) write_atomic(file_for(id), JSON.pretty_generate(stringify_keys(p))) p end |
#status_of(proposal_id) ⇒ Symbol
Non-blocking status check.
80 81 82 83 84 85 86 87 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 80 def status_of(proposal_id) p = read_proposal(proposal_id) return :not_found unless p return :expired if Time.parse(p['expires_at']) < @clock.call d = read_decision(proposal_id) return :pending unless d d['decision'] == 'approve' ? :approved : :rejected end |
#verify_proposal_integrity(proposal_id) ⇒ Boolean
Verify proposal content integrity at apply time.
121 122 123 124 125 126 127 128 129 130 |
# File 'lib/kairos_mcp/daemon/approval_gate.rb', line 121 def verify_proposal_integrity(proposal_id) p = read_proposal(proposal_id) return false unless p d = read_decision(proposal_id) return false unless d canonical = p.reject { |k, _| mutable_key?(k) } recomputed = canonical_hash(canonical) recomputed == p['proposal_hash'] && d['proposal_hash'] == p['proposal_hash'] end |