cardinal-ai 0.2.20 → 0.2.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f598747488ba4b10c50dde4edca8d8d1bbad8561966950f8257d2d36d6855925
4
- data.tar.gz: c197838268819214088cde422122e748684b6b3d0607f96eae69b5b140895b2b
3
+ metadata.gz: 5ccd0005599829484b375d90a86df2083d588f83cc3cc79a608b8ea95e54dbd9
4
+ data.tar.gz: 15926c269aa08cfb60446f12c7cf374d8d03876d2cbc55c6b78508aadd5bc4c3
5
5
  SHA512:
6
- metadata.gz: abd46dd6087001a326abf6afa1d8530c66f445758d23a5646731482c5c7c246bd4bca345732518cf6408665f4c35ccc4ddaa49fd8dae1467ef9f175fb7b18890
7
- data.tar.gz: c90684313c0e3a93125e6d8f2fa419013d4ed451e7cb255b74adf041998b5b8e28edcb13974cd5b7d88831a2641a41e36b7f924cd5c3114f5d6da34aa9b272bc
6
+ metadata.gz: 76322a4285ed47b9d60c2ceb7df46ae7bada9c0a49591ba9c348806324d947fd096f3e019334f737331ad44fdbda5b120cc162fbdabdfb0c046fa8a2de154e7b
7
+ data.tar.gz: '09ecec2c7b748a1b1d9441838cad40ca3b90e377cb5f61faa8bdef08ea5d662cc4b2de2f00c9cd76ae7e6c1724bce60881040aa1274efc6fadd73947bf207157'
@@ -176,6 +176,13 @@ a { color: var(--blue); text-decoration: none; }
176
176
  .share-status.share-err { color: var(--red); animation: none; }
177
177
  @keyframes share-fade { 0%, 70% { opacity: 1; } 100% { opacity: 0; } }
178
178
 
179
+ /* Ask-first permission callouts (§ permissions) */
180
+ .callout-permission { border-color: var(--amber); }
181
+ .callout-permission.resolved { opacity: .75; }
182
+ .perm-command { display: block; padding: 6px 8px; background: var(--surface); border-radius: 6px;
183
+ margin: 6px 0; overflow-wrap: anywhere; }
184
+ .perm-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 4px; }
185
+
179
186
  .pull-form { display: inline; }
180
187
  #repo-pull-status { font-size: 0.85rem; }
181
188
  #repo-pull-status .pull-ok { color: var(--green); }
@@ -29,9 +29,10 @@ class BoardsController < ApplicationController
29
29
 
30
30
  def update
31
31
  board = Board.first!
32
- attrs = params.require(:board).permit(:name, :default_branch, :permission_bypass, archive_accepts_from: [])
32
+ attrs = params.require(:board).permit(:name, :default_branch, :permission_bypass, :permission_mode, archive_accepts_from: [])
33
33
  board.archive_accepts_from = attrs[:archive_accepts_from].to_a.map(&:to_s).reject(&:blank?) if attrs.key?(:archive_accepts_from)
34
34
  board.permission_bypass = (attrs[:permission_bypass] == "1") if attrs.key?(:permission_bypass)
35
+ board.settings["permission_mode"] = attrs[:permission_mode].presence_in(Board::PERMISSION_MODES) if attrs.key?(:permission_mode)
35
36
  board.update!(
36
37
  name: attrs[:name].presence || board.name,
37
38
  default_branch: attrs[:default_branch].presence || board.default_branch
@@ -26,7 +26,7 @@ class ColumnsController < ApplicationController
26
26
  :name, :archetype, :instructions, :model, :effort,
27
27
  :concurrency_limit, :max_turns, :timeout_minutes, :plan_approval,
28
28
  :on_entry_text, :on_entry_json, :color, :custom_color, :arrivals, :ai,
29
- :shell, :footer_text, accepts_from: []
29
+ :shell, :allowed_commands_text, :footer_text, accepts_from: []
30
30
  )
31
31
 
32
32
  policy = @column.policy.dup
@@ -39,6 +39,9 @@ class ColumnsController < ApplicationController
39
39
  policy["arrivals"] = attrs[:arrivals].presence_in(%w[top bottom])
40
40
  policy["ai"] = (attrs[:ai] == "1") if attrs.key?(:ai) # inbox forms omit it — never AI anyway
41
41
  policy["shell"] = (attrs[:shell] == "1") if attrs.key?(:shell) # worker shell access (execution gear only)
42
+ if attrs.key?(:allowed_commands_text)
43
+ policy["allowed_commands"] = attrs[:allowed_commands_text].to_s.lines.map(&:strip).reject(&:blank?).presence
44
+ end
42
45
  # Accept policy (card #15): store allowed source column ids as strings.
43
46
  # EXPLICIT ONLY — an empty list means the column accepts from nowhere.
44
47
  policy["accepts_from"] = attrs[:accepts_from].to_a.map(&:to_s).reject(&:blank?).presence
@@ -5,11 +5,23 @@ class MessagesController < ApplicationController
5
5
  parked_run = card.runs.where(status: "needs_input").order(:id).last
6
6
  live_run = card.runs.where(status: "running").order(:id).last
7
7
 
8
+ pending_permission = live_run && live_run.permission_requests.pending.order(:id).first
9
+
8
10
  if params.dig(:message, :note) == "1"
9
11
  # Note only (card #47): on the record for any FUTURE reader — the next
10
12
  # column's assistant or worker sees it in conversation context — but no
11
13
  # AI is invoked now and no verdict changes. Type, drag, done.
12
14
  card.log!("user_message", actor: "user", text: text, note: true)
15
+ elsif pending_permission
16
+ # A chat reply while the agent waits on permission IS the verdict:
17
+ # a plain yes approves; anything else denies, with the user's words as
18
+ # the reason the agent reads (deny-with-reason is steering).
19
+ card.log!("user_message", actor: "user", run: live_run, text: text)
20
+ if text.strip.match?(/\A(y|yes|ok|okay|allow|approve|👍)\z/i)
21
+ pending_permission.resolve!("allowed")
22
+ else
23
+ pending_permission.resolve!("denied", message: text)
24
+ end
13
25
  elsif parked_run
14
26
  # Answer / plan feedback: goes back into the same agent session.
15
27
  kind = parked_run.phase == "plan" ? "user_message" : "answer"
@@ -0,0 +1,55 @@
1
+ # The two faces of ask-first mode: the shim's API (create/show/auto-deny —
2
+ # CSRF-exempt: it's a local process with no session) and the human's verdict
3
+ # (answer, from timeline buttons).
4
+ class PermissionRequestsController < ApplicationController
5
+ skip_before_action :verify_authenticity_token, only: [:create, :show, :answer]
6
+
7
+ def create
8
+ run = Run.find(params.require(:run_id))
9
+ input = params[:input].respond_to?(:to_unsafe_h) ? params[:input].to_unsafe_h : {}
10
+ request_row = run.permission_requests.create!(
11
+ tool_name: params.require(:tool_name), input: input,
12
+ command: input["command"].presence
13
+ )
14
+
15
+ if request_row.auto_allowed?
16
+ request_row.update!(status: "allowed", answered_at: Time.current)
17
+ run.card.log!("status_change", run: run, text: "🔐 Auto-approved #{request_row.describe} (pre-approved pattern)")
18
+ else
19
+ run.card.log!("permission_request", actor: "agent", run: run,
20
+ request_id: request_row.id, tool: request_row.tool_name,
21
+ command: request_row.command,
22
+ text: "🔐 Wants to run #{request_row.describe}")
23
+ run.card.update!(status: "needs_input") if run.card.working?
24
+ end
25
+ render json: { id: request_row.id, status: request_row.status, message: request_row.message }
26
+ end
27
+
28
+ def show
29
+ request_row = PermissionRequest.find(params[:id])
30
+ # The blocked claude process is alive and waiting — the shim's poll is its
31
+ # proof of life for the sweeper.
32
+ request_row.run.update_columns(heartbeat_at: Time.current)
33
+ render json: { id: request_row.id, status: request_row.status, message: request_row.message }
34
+ end
35
+
36
+ def answer
37
+ request_row = PermissionRequest.find(params[:id])
38
+ if params[:auto] == "1"
39
+ request_row.resolve!("auto_denied", actor: "system")
40
+ elsif params[:verdict] == "allow"
41
+ request_row.resolve!("allowed", always: params[:always] == "1")
42
+ else
43
+ request_row.resolve!("denied", message: params[:message])
44
+ end
45
+
46
+ respond_to do |format|
47
+ format.turbo_stream do
48
+ render turbo_stream: turbo_stream.replace("permission_request_#{request_row.id}",
49
+ partial: "events/permission_request_state", locals: { request: request_row })
50
+ end
51
+ format.json { render json: { ok: true } }
52
+ format.html { redirect_to card_path(request_row.run.card) }
53
+ end
54
+ end
55
+ end
data/app/models/board.rb CHANGED
@@ -57,12 +57,20 @@ class Board < ApplicationRecord
57
57
  # button is always available; this governs only the drag affordance.
58
58
  store_accessor :settings, :archive_accepts_from, :permission_bypass
59
59
 
60
- # Board default for worker autonomy (§ permissions): true (default) lets
61
- # agents act without asking full shell inside their workspace. false
62
- # restricts workers to file tools (read/search/edit/write; Cardinal commits
63
- # for them) unless a card explicitly overrides back to full autonomy.
60
+ # Board default for worker autonomy (§ permissions), three honest modes:
61
+ # bypass — agents act without asking (full shell)
62
+ # ask — shell commands pause for your 👍 in the card chat
63
+ # restricted file tools only; agents cannot execute anything
64
+ # (Back-compat: the earlier boolean permission_bypass=false maps to restricted.)
65
+ PERMISSION_MODES = %w[bypass ask restricted].freeze
66
+
67
+ def permission_mode_default
68
+ settings["permission_mode"].presence_in(PERMISSION_MODES) ||
69
+ (settings["permission_bypass"] == false ? "restricted" : "bypass")
70
+ end
71
+
64
72
  def permission_bypass?
65
- settings["permission_bypass"] != false
73
+ permission_mode_default == "bypass"
66
74
  end
67
75
 
68
76
  def archive_accepts?(column)
data/app/models/card.rb CHANGED
@@ -92,16 +92,16 @@ class Card < ApplicationRecord
92
92
  # default", so existing cards are unaffected. Read fresh at every segment
93
93
  # spawn (start, restart, resume) by the runner and the planning assistant, so
94
94
  # a change takes effect on the next segment — never on an already-running one.
95
- # Permission override (nil = board default): "bypass" forces full autonomy
96
- # for this card, "ask" forces the restricted file-tools mode.
97
- PERMISSION_MODES = ["bypass", "ask"].freeze
95
+ # Permission override (nil = board default): bypass / ask / restricted —
96
+ # see Board::PERMISSION_MODES for what each means.
97
+ PERMISSION_MODES = Board::PERMISSION_MODES
98
+
99
+ def effective_permission_mode
100
+ permission_mode.presence_in(PERMISSION_MODES) || board.permission_mode_default
101
+ end
98
102
 
99
103
  def effective_permission_bypass?
100
- case permission_mode
101
- when "bypass" then true
102
- when "ask" then false
103
- else board.permission_bypass?
104
- end
104
+ effective_permission_mode == "bypass"
105
105
  end
106
106
 
107
107
  def effective_model = model.presence || column.model
data/app/models/event.rb CHANGED
@@ -5,7 +5,7 @@ class Event < ApplicationRecord
5
5
  question answer progress
6
6
  tool_call tool_result artifact_created
7
7
  run_started run_finished final_report error
8
- ].freeze
8
+ permission_request].freeze
9
9
 
10
10
  # Which timeline zoom level an event first appears at (§7).
11
11
  CONVERSATION_KINDS = %w[user_message agent_message assistant_message question answer
@@ -0,0 +1,62 @@
1
+ # One "may I run this?" from an agent in ask-first mode (§ permissions). The
2
+ # claude process is ALIVE and blocked while this is pending — the MCP shim
3
+ # polls for the verdict and the poll keeps the run's heartbeat fresh.
4
+ class PermissionRequest < ApplicationRecord
5
+ STATUSES = %w[pending allowed denied auto_denied].freeze
6
+
7
+ belongs_to :run
8
+
9
+ validates :status, inclusion: { in: STATUSES }
10
+
11
+ scope :pending, -> { where(status: "pending") }
12
+
13
+ def pending? = status == "pending"
14
+
15
+ def describe
16
+ tool_name == "Bash" && command.present? ? "`#{command.truncate(120)}`" : tool_name
17
+ end
18
+
19
+ # The single verdict path — buttons, chat replies, and the shim's timeout
20
+ # all land here. `always` remembers the pattern for the rest of the run.
21
+ def resolve!(verdict, message: nil, always: false, actor: "user")
22
+ return unless pending?
23
+
24
+ update!(status: verdict, message: message.presence, answered_at: Time.current)
25
+ card = run.card
26
+
27
+ if verdict == "allowed" && always
28
+ pattern = tool_name == "Bash" ? command.to_s : tool_name
29
+ patterns = Array(run.briefing["allowed_patterns"]) | [pattern]
30
+ run.update!(briefing: run.briefing.merge("allowed_patterns" => patterns))
31
+ end
32
+
33
+ text =
34
+ case verdict
35
+ when "allowed" then "🔐 Approved #{describe}#{" — and anything matching it for the rest of this run" if always}"
36
+ when "auto_denied" then "🔐 #{describe} auto-denied (no answer in time) — the agent was told to adapt or park"
37
+ else "🔐 Denied #{describe}#{": “#{message}”" if message.present?}"
38
+ end
39
+ card.log!("status_change", actor: actor, run: run, text: text)
40
+ card.update!(status: "working") if card.needs_input? && run.permission_requests.pending.none?
41
+ broadcast_resolution
42
+ end
43
+
44
+ # Does an existing pattern (column pre-approved list or earlier
45
+ # allow-for-this-run) already cover this request?
46
+ def auto_allowed?
47
+ patterns = Array(run.card.column.policy["allowed_commands"]) + Array(run.briefing["allowed_patterns"])
48
+ if tool_name == "Bash"
49
+ patterns.any? { |p| p.present? && command.to_s.start_with?(p.strip) }
50
+ else
51
+ patterns.map(&:strip).include?(tool_name)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # Re-render the timeline callout everywhere (buttons → resolved line).
58
+ def broadcast_resolution
59
+ broadcast_replace_to run.card, target: "permission_request_#{id}",
60
+ partial: "events/permission_request_state", locals: { request: self }
61
+ end
62
+ end
data/app/models/run.rb CHANGED
@@ -4,6 +4,7 @@ class Run < ApplicationRecord
4
4
  belongs_to :agent_session
5
5
  has_one :card, through: :agent_session
6
6
  has_many :artifacts, dependent: :destroy
7
+ has_many :permission_requests, dependent: :destroy
7
8
  has_many :events, dependent: :nullify
8
9
 
9
10
  enum :status, STATUSES.index_by(&:itself)
@@ -45,6 +45,23 @@ module Agent
45
45
  - Finish with a concise report: what you did, what to check, any open questions.
46
46
  RULES
47
47
 
48
+ # Ask-first mode: full toolset, but shell commands pause for the user's
49
+ # approval (routed through the Cardinal permission shim).
50
+ ASK_EXECUTE_RULES = <<~RULES.freeze
51
+ ## Rules
52
+ - You have the full toolset, but shell commands PAUSE for the user's approval.
53
+ A denial comes with their reason — adapt to it rather than retrying the same thing.
54
+ - Approvals can take a while; the user may also pre-approve a pattern so similar
55
+ commands run without asking again.
56
+ - Work only inside this repository checkout (you are already on the card's branch).
57
+ - Commit your work as you go with clear messages (commits need approval like any
58
+ command). Do NOT push — the runner pushes for you.
59
+ - Stay strictly within the card's scope. Prefer the smallest reasonable interpretation and note assumptions.
60
+ - If denials leave you genuinely blocked, output a single line starting with
61
+ "QUESTION:" followed by the question, then stop immediately.
62
+ - Finish with a concise report: what you did, what to check, any open questions.
63
+ RULES
64
+
48
65
  def self.start(run) = new(run).start
49
66
  def self.resume(run, message, approve: false) = new(run).resume(message, approve: approve)
50
67
 
@@ -113,8 +130,17 @@ module Agent
113
130
  workspace = resuming ? Workspace.attach(card) : Workspace.provision(card)
114
131
  remember_base_sha(workspace) if mode == "execute"
115
132
 
116
- cmd = ["claude", "-p", prompt, "--output-format", "stream-json", "--verbose",
117
- "--permission-mode", "bypassPermissions"]
133
+ cmd = ["claude", "-p", prompt, "--output-format", "stream-json", "--verbose"]
134
+ if ask_mode? && mode != "plan" && mode != "plan_wrap"
135
+ # Ask-first: permission requests route to our MCP shim, which parks
136
+ # them on the card for a chat/button verdict. The claude process
137
+ # stays alive, blocked, until the answer arrives.
138
+ cmd += ["--permission-mode", "default",
139
+ "--permission-prompt-tool", "mcp__cardinal__permission",
140
+ "--mcp-config", permission_shim_config]
141
+ else
142
+ cmd += ["--permission-mode", "bypassPermissions"]
143
+ end
118
144
  case mode
119
145
  when "plan"
120
146
  # Read-only exploration for the plan phase. (--permission-mode plan
@@ -147,7 +173,17 @@ module Agent
147
173
  run.agent_session.update!(status: "ready", config: run.agent_session.config.merge("pid" => wait.pid))
148
174
  timeout_min = (column.timeout_minutes.presence || 30).to_i
149
175
  watchdog = Thread.new do
150
- sleep timeout_min * 60
176
+ deadline = Time.current + timeout_min * 60
177
+ loop do
178
+ sleep 15
179
+ # Waiting on a human is not running away — a pending permission
180
+ # request resets the clock instead of counting against it.
181
+ waiting = ActiveRecord::Base.connection_pool.with_connection do
182
+ PermissionRequest.where(run_id: run.id, status: "pending").exists?
183
+ end
184
+ deadline = Time.current + timeout_min * 60 if waiting
185
+ break if Time.current > deadline
186
+ end
151
187
  @timed_out = true
152
188
  Process.kill("TERM", wait.pid) rescue nil
153
189
  end
@@ -381,14 +417,37 @@ module Agent
381
417
  end
382
418
 
383
419
  def execute_rules
384
- restricted_tools? ? RESTRICTED_EXECUTE_RULES : EXECUTE_RULES
420
+ return RESTRICTED_EXECUTE_RULES if restricted_tools?
421
+ ask_mode? ? ASK_EXECUTE_RULES : EXECUTE_RULES
422
+ end
423
+
424
+ # Ask-first applies when resolved mode is "ask" and the column's shell
425
+ # toggle hasn't already restricted the agent to file tools.
426
+ def ask_mode?
427
+ column.shell_access? && card.effective_permission_mode == "ask"
428
+ end
429
+
430
+ def permission_shim_config
431
+ {
432
+ mcpServers: {
433
+ cardinal: {
434
+ command: RbConfig.ruby,
435
+ args: [Rails.root.join("lib", "cardinal", "permission_shim.rb").to_s],
436
+ env: {
437
+ "CARDINAL_URL" => "http://127.0.0.1:#{ENV.fetch("PORT", "3000")}",
438
+ "CARDINAL_RUN_ID" => run.id.to_s,
439
+ "CARDINAL_PERMISSION_TIMEOUT" => "600"
440
+ }
441
+ }
442
+ }
443
+ }.to_json
385
444
  end
386
445
 
387
446
  # Three voices, one verdict: the column's shell toggle, the board's
388
447
  # permission default, and the card's own override (which beats the board
389
448
  # but never re-opens a column that turned shell off).
390
449
  def restricted_tools?
391
- !column.shell_access? || !card.effective_permission_bypass?
450
+ !column.shell_access? || card.effective_permission_mode == "restricted"
392
451
  end
393
452
 
394
453
  def plan_prompt
@@ -17,11 +17,12 @@
17
17
  <label>Default branch <%= info_tip("The branch agents fork card branches from and Done merges pull requests toward. Detected from the repo at first boot; fix it here if that guess was wrong.") %></label>
18
18
  <%= f.text_field :default_branch, placeholder: "main" %>
19
19
 
20
- <label class="check-row">
21
- <%= f.check_box :permission_bypass, { checked: @board.permission_bypass? }, "1", "0" %>
22
- Agents act without asking (full autonomy)
23
- <%= info_tip("On (default): worker agents run shell commands tests, builds, git inside their workspace without permission prompts. Off: workers get file tools only (read, search, edit, write); they cannot execute anything, and Cardinal commits their edits for them. Each card can override this in its 🧠 panel; a column whose Shell access is off stays restricted regardless.") %>
24
- </label>
20
+ <label>Agent permissions <%= info_tip("Full autonomy (default): workers run shell commands without asking. Ask first: every command pauses on the card — approve with a button or a chat reply; pre-approve patterns per column or per run. File tools only: workers cannot execute anything; Cardinal commits their edits. Each card can override this in its 🧠 panel; a column whose Shell access is off stays restricted regardless.") %></label>
21
+ <%= f.select :permission_mode,
22
+ [["Full autonomy — agents act without asking", "bypass"],
23
+ ["Ask first commands pause for your 👍 on the card", "ask"],
24
+ ["File tools only — agents cannot execute anything", "restricted"]],
25
+ selected: @board.permission_mode_default %>
25
26
  <% end %>
26
27
 
27
28
  <label>Repository</label>
@@ -180,9 +180,10 @@
180
180
  <div>
181
181
  <label>Permissions <%= info_tip("Overrides the board's autonomy default for THIS card. Full autonomy: the worker runs shell commands without asking. Restricted: file tools only — it cannot execute anything, and Cardinal commits its edits. A column whose Shell access is off stays restricted regardless.") %></label>
182
182
  <%= f.select :permission_mode,
183
- [["Board default (#{@card.board.permission_bypass? ? "full autonomy" : "restricted"})", ""],
183
+ [["Board default (#{{ "bypass" => "full autonomy", "ask" => "ask first", "restricted" => "file tools only" }[@card.board.permission_mode_default]})", ""],
184
184
  ["Full autonomy — acts without asking", "bypass"],
185
- ["Restrictedfile tools only", "ask"]],
185
+ ["Ask first commands pause for your 👍", "ask"],
186
+ ["File tools only — cannot execute", "restricted"]],
186
187
  selected: @card.permission_mode.to_s %>
187
188
  </div>
188
189
  </div>
@@ -111,6 +111,11 @@
111
111
  Shell access — the agent may run commands (tests, builds, git)
112
112
  <%= info_tip("Unchecked: the agent gets file tools only (read, search, edit, write) and physically cannot execute anything — Cardinal commits and pushes its edits for it. Safer, but it can't run your tests or generators, and the agent process runs on this machine either way.") %>
113
113
  </label>
114
+
115
+ <label>Pre-approved commands <%= info_tip("Only matters in ask-first permission mode: commands starting with any line here run WITHOUT asking. One pattern per line — e.g. \"bundle exec rails test\" or \"git commit\". Everything else still pauses for your verdict.") %></label>
116
+ <%= f.text_area :allowed_commands_text, rows: 2, class: "mono",
117
+ value: Array(@column.policy["allowed_commands"]).join("\n"),
118
+ placeholder: "bundle exec rails test\ngit commit" %>
114
119
  <% end %>
115
120
 
116
121
  </div>
@@ -3,6 +3,11 @@
3
3
  <span><%= event.payload["from"] %> → <%= event.payload["to"] %></span>
4
4
  <time><%= event.created_at.strftime("%b %-d %H:%M") %></time>
5
5
  </div>
6
+ <% elsif event.kind == "permission_request" %>
7
+ <% request = PermissionRequest.find_by(id: event.payload["request_id"]) %>
8
+ <% if request %>
9
+ <%= render "events/permission_request_state", request: request %>
10
+ <% end %>
6
11
  <% elsif event.kind == "move_rejected" %>
7
12
  <div class="move-rejected-row">
8
13
  <span>⤺ <%= event.text %></span>
@@ -0,0 +1,17 @@
1
+ <div id="permission_request_<%= request.id %>" class="panel-callout callout-permission<%= " resolved" unless request.pending? %>">
2
+ <% if request.pending? %>
3
+ <p><strong>🔐 The agent wants to run:</strong></p>
4
+ <p><code class="perm-command"><%= request.describe %></code></p>
5
+ <div class="perm-actions">
6
+ <%= button_to "✅ Allow", answer_permission_request_path(request, verdict: "allow"), class: "approve-btn" %>
7
+ <%= button_to "✅ Allow similar for this run", answer_permission_request_path(request, verdict: "allow", always: "1"),
8
+ class: "theme-toggle", title: "Also pre-approves anything starting with this command for the rest of the run" %>
9
+ <%= button_to "❌ Deny", answer_permission_request_path(request, verdict: "deny"), class: "cancel-btn" %>
10
+ </div>
11
+ <p class="hint">Or reply in the chat: “yes” approves — anything else denies, with your words as the reason the agent reads.</p>
12
+ <% else %>
13
+ <p>🔐 <%= request.describe %> —
14
+ <strong><%= { "allowed" => "approved", "denied" => "denied", "auto_denied" => "auto-denied (no answer in time)" }[request.status] %></strong><%= " · “#{request.message}”" if request.message.present? %>
15
+ </p>
16
+ <% end %>
17
+ </div>
data/config/boot.rb CHANGED
@@ -6,6 +6,19 @@
6
6
  # belong in an installed gem directory).
7
7
  if ENV["CARDINAL_GEM"] == "1"
8
8
  require "rubygems"
9
+ # Activate our own gem up front: bare `require`s pick the newest installed
10
+ # version of each dependency, ignoring the gemspec's constraints — in a
11
+ # shared gemset (host app + cardinal) that can activate a version we don't
12
+ # support. Activating cardinal-ai pins the whole tree to the gemspec.
13
+ begin
14
+ require_relative "../lib/cardinal/version"
15
+ gem "cardinal-ai", Cardinal::VERSION
16
+ rescue Gem::LoadError
17
+ # Checkout run with CARDINAL_GEM=1 but no installed gem — requires below
18
+ # fall back to newest-installed. A missing/conflicting DEPENDENCY of an
19
+ # installed gem must surface here, not as a NameError deep in the app.
20
+ raise unless Gem::Specification.find_all_by_name("cardinal-ai").empty?
21
+ end
9
22
  else
10
23
  ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
11
24
  require "bundler/setup" # Set up gems listed in the Gemfile.
data/config/routes.rb CHANGED
@@ -21,6 +21,10 @@ Rails.application.routes.draw do
21
21
  end
22
22
  resources :messages, only: [:create]
23
23
  end
24
+ resources :permission_requests, only: [:create, :show] do
25
+ member { post :answer }
26
+ end
27
+
24
28
  get "asana/new" => "asana#new_card", as: :asana_new_card
25
29
  post "asana/connect" => "asana#connect", as: :asana_connect
26
30
  post "asana/import" => "asana#import", as: :asana_import
@@ -0,0 +1,16 @@
1
+ # Ask-first permission mode: one row per "may I run this?" from an agent.
2
+ class CreatePermissionRequests < ActiveRecord::Migration[8.1]
3
+ def change
4
+ create_table :permission_requests do |t|
5
+ t.references :run, null: false, foreign_key: true
6
+ t.string :tool_name, null: false
7
+ t.text :command # Bash command text, for display + pattern matching
8
+ t.json :input, default: {}
9
+ t.string :status, null: false, default: "pending" # pending/allowed/denied/auto_denied
10
+ t.text :message # denial reason shown to the agent
11
+ t.datetime :answered_at
12
+ t.datetime :created_at, null: false
13
+ end
14
+ add_index :permission_requests, [:run_id, :status]
15
+ end
16
+ end
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Cardinal's permission shim (§ ask-first mode): a minimal MCP stdio server
5
+ # the claude CLI calls via --permission-prompt-tool. Each permission request
6
+ # becomes a PermissionRequest on the card (POST to the local Cardinal server);
7
+ # we then poll for the human's verdict — the poll doubles as the run's
8
+ # heartbeat — and hand claude back allow/deny. Pure stdlib; fails CLOSED
9
+ # (deny) on any transport error.
10
+ require "json"
11
+ require "net/http"
12
+ require "uri"
13
+
14
+ class CardinalPermissionShim
15
+ PROTOCOL = "2024-11-05"
16
+
17
+ def initialize(input: $stdin, output: $stdout,
18
+ base_url: ENV["CARDINAL_URL"], run_id: ENV["CARDINAL_RUN_ID"],
19
+ timeout: ENV.fetch("CARDINAL_PERMISSION_TIMEOUT", "600").to_i)
20
+ @input, @output = input, output
21
+ @base_url, @run_id, @timeout = base_url, run_id, timeout
22
+ @output.sync = true
23
+ end
24
+
25
+ def run
26
+ while (line = @input.gets)
27
+ line = line.strip
28
+ next if line.empty?
29
+ handle(JSON.parse(line))
30
+ end
31
+ rescue Interrupt
32
+ nil
33
+ end
34
+
35
+ def handle(msg)
36
+ case msg["method"]
37
+ when "initialize"
38
+ reply(msg["id"], protocolVersion: msg.dig("params", "protocolVersion") || PROTOCOL,
39
+ capabilities: { tools: {} },
40
+ serverInfo: { name: "cardinal", version: "1.0" })
41
+ when "tools/list"
42
+ reply(msg["id"], tools: [{
43
+ name: "permission",
44
+ description: "Ask the Cardinal board's user to approve or deny a tool use.",
45
+ inputSchema: { type: "object",
46
+ properties: { tool_name: { type: "string" }, input: { type: "object" } },
47
+ required: ["tool_name"] }
48
+ }])
49
+ when "tools/call"
50
+ args = msg.dig("params", "arguments") || {}
51
+ decision = decide(args)
52
+ reply(msg["id"], content: [{ type: "text", text: decision.to_json }])
53
+ when "ping"
54
+ reply(msg["id"], {})
55
+ else
56
+ reply(msg["id"], {}) if msg["id"] # politely ack anything else that expects an answer
57
+ end
58
+ end
59
+
60
+ # POST the request, poll until answered, translate to the CLI's contract.
61
+ def decide(args)
62
+ created = post("/permission_requests",
63
+ run_id: @run_id, tool_name: args["tool_name"].to_s, input: args["input"] || {})
64
+ return deny("Cardinal couldn't record the permission request — denied for safety.") unless created
65
+
66
+ waited = 0
67
+ status, message = created["status"], created["message"]
68
+ while status == "pending"
69
+ if waited >= @timeout
70
+ post("/permission_requests/#{created["id"]}/answer", verdict: "deny", auto: "1")
71
+ return deny("No answer from the user in time. Work around this without the command, or park with a QUESTION:.")
72
+ end
73
+ sleep 2
74
+ waited += 2
75
+ current = get("/permission_requests/#{created["id"]}")
76
+ status, message = current["status"], current["message"] if current
77
+ end
78
+
79
+ if status == "allowed"
80
+ { "behavior" => "allow", "updatedInput" => args["input"] || {} }
81
+ else
82
+ deny(message.to_s.empty? ? "The user denied this action." : "The user denied this: #{message}")
83
+ end
84
+ end
85
+
86
+ def deny(message) = { "behavior" => "deny", "message" => message }
87
+
88
+ def reply(id, payload)
89
+ @output.puts({ jsonrpc: "2.0", id: id, result: payload }.to_json)
90
+ end
91
+
92
+ def post(path, body)
93
+ http_json(Net::HTTP::Post, path, body)
94
+ end
95
+
96
+ def get(path)
97
+ http_json(Net::HTTP::Get, path, nil)
98
+ end
99
+
100
+ def http_json(klass, path, body)
101
+ uri = URI("#{@base_url}#{path}")
102
+ req = klass.new(uri.request_uri, "Content-Type" => "application/json", "Accept" => "application/json")
103
+ req.body = body.to_json if body
104
+ res = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) { |h| h.request(req) }
105
+ res.code.to_i.between?(200, 299) ? JSON.parse(res.body) : nil
106
+ rescue StandardError => e
107
+ warn "cardinal-shim: #{e.class}: #{e.message}"
108
+ nil
109
+ end
110
+ end
111
+
112
+ CardinalPermissionShim.new.run if __FILE__ == $PROGRAM_NAME
@@ -1,3 +1,3 @@
1
1
  module Cardinal
2
- VERSION = "0.2.20"
2
+ VERSION = "0.2.22"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cardinal-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.20
4
+ version: 0.2.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jason Ellis
@@ -27,16 +27,16 @@ dependencies:
27
27
  name: propshaft
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - ">="
30
+ - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: '1.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - ">="
37
+ - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: '1.0'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: sqlite3
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -69,44 +69,44 @@ dependencies:
69
69
  name: importmap-rails
70
70
  requirement: !ruby/object:Gem::Requirement
71
71
  requirements:
72
- - - ">="
72
+ - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '0'
74
+ version: '2.1'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
- - - ">="
79
+ - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '0'
81
+ version: '2.1'
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: turbo-rails
84
84
  requirement: !ruby/object:Gem::Requirement
85
85
  requirements:
86
- - - ">="
86
+ - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: '0'
88
+ version: '2.0'
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
- - - ">="
93
+ - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: '0'
95
+ version: '2.0'
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: stimulus-rails
98
98
  requirement: !ruby/object:Gem::Requirement
99
99
  requirements:
100
- - - ">="
100
+ - - "~>"
101
101
  - !ruby/object:Gem::Version
102
- version: '0'
102
+ version: '1.3'
103
103
  type: :runtime
104
104
  prerelease: false
105
105
  version_requirements: !ruby/object:Gem::Requirement
106
106
  requirements:
107
- - - ">="
107
+ - - "~>"
108
108
  - !ruby/object:Gem::Version
109
- version: '0'
109
+ version: '1.3'
110
110
  - !ruby/object:Gem::Dependency
111
111
  name: redcarpet
112
112
  requirement: !ruby/object:Gem::Requirement
@@ -168,6 +168,7 @@ files:
168
168
  - app/controllers/cards_controller.rb
169
169
  - app/controllers/columns_controller.rb
170
170
  - app/controllers/messages_controller.rb
171
+ - app/controllers/permission_requests_controller.rb
171
172
  - app/controllers/runs_controller.rb
172
173
  - app/helpers/application_helper.rb
173
174
  - app/javascript/application.js
@@ -207,6 +208,7 @@ files:
207
208
  - app/models/column.rb
208
209
  - app/models/concerns/model_labeling.rb
209
210
  - app/models/event.rb
211
+ - app/models/permission_request.rb
210
212
  - app/models/run.rb
211
213
  - app/services/agent/runner.rb
212
214
  - app/services/agent/workspace.rb
@@ -234,6 +236,7 @@ files:
234
236
  - app/views/columns/_column.html.erb
235
237
  - app/views/columns/edit.html.erb
236
238
  - app/views/events/_event.html.erb
239
+ - app/views/events/_permission_request_state.html.erb
237
240
  - app/views/layouts/application.html.erb
238
241
  - app/views/layouts/mailer.html.erb
239
242
  - app/views/layouts/mailer.text.erb
@@ -281,11 +284,13 @@ files:
281
284
  - db/migrate/20260705193935_add_settings_to_boards.rb
282
285
  - db/migrate/20260705225451_add_asana_url_to_cards.rb
283
286
  - db/migrate/20260706002904_add_permission_mode_to_cards.rb
287
+ - db/migrate/20260706141319_create_permission_requests.rb
284
288
  - db/queue_schema.rb
285
289
  - db/seeds.rb
286
290
  - docker/agent/Dockerfile
287
291
  - exe/cardinal
288
292
  - lib/cardinal.rb
293
+ - lib/cardinal/permission_shim.rb
289
294
  - lib/cardinal/version.rb
290
295
  - public/400.html
291
296
  - public/404.html