Class: KairosMcp::Daemon::ApprovalGate

Inherits:
Object
  • Object
show all
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

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).

Raises:

  • (ArgumentError)


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_proposalsObject

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).

Raises:

  • (ArgumentError)


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.

Raises:

  • (ArgumentError)


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.

Returns:

  • (Symbol)

    :pending | :approved | :rejected | :expired | :not_found



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.

Returns:

  • (Boolean)


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