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 +4 -4
- data/app/assets/stylesheets/cardinal.css +7 -0
- data/app/controllers/boards_controller.rb +2 -1
- data/app/controllers/columns_controller.rb +4 -1
- data/app/controllers/messages_controller.rb +12 -0
- data/app/controllers/permission_requests_controller.rb +55 -0
- data/app/models/board.rb +13 -5
- data/app/models/card.rb +8 -8
- data/app/models/event.rb +1 -1
- data/app/models/permission_request.rb +62 -0
- data/app/models/run.rb +1 -0
- data/app/services/agent/runner.rb +64 -5
- data/app/views/boards/edit.html.erb +6 -5
- data/app/views/cards/_detail.html.erb +3 -2
- data/app/views/columns/edit.html.erb +5 -0
- data/app/views/events/_event.html.erb +5 -0
- data/app/views/events/_permission_request_state.html.erb +17 -0
- data/config/boot.rb +13 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20260706141319_create_permission_requests.rb +16 -0
- data/lib/cardinal/permission_shim.rb +112 -0
- data/lib/cardinal/version.rb +1 -1
- metadata +22 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5ccd0005599829484b375d90a86df2083d588f83cc3cc79a608b8ea95e54dbd9
|
|
4
|
+
data.tar.gz: 15926c269aa08cfb60446f12c7cf374d8d03876d2cbc55c6b78508aadd5bc4c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
61
|
-
# agents act without asking
|
|
62
|
-
#
|
|
63
|
-
#
|
|
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
|
-
|
|
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):
|
|
96
|
-
#
|
|
97
|
-
PERMISSION_MODES =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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? ||
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 (#{
|
|
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
|
-
["
|
|
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
|
data/lib/cardinal/version.rb
CHANGED
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.
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|