Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,34 @@ If you want more flexibility to customize your Fizzy installation by changing it
You are welcome -- and encouraged -- to modify Fizzy to your liking.
Please see our [Development guide](docs/development.md) for how to get Fizzy set up for local development.

### CLI tool

From this checkout, install the standalone CLI with:

```bash
./bin/install-fizzy-cli
```

This also accepts the compatibility path:

```bash
./bin/install/fizzy/cli
```

Then run:

```bash
fizzy --version
```

If the command is not immediately on PATH, the installer writes a launcher at `~/.local/bin/fizzy` and updates your shell startup file when possible.

You can also use the in-repo wrapper without installing:

```bash
./bin/fizzy --help
```


## Contributing

Expand Down
45 changes: 45 additions & 0 deletions app/controllers/agent_bootstrap_claims_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
class AgentBootstrapClaimsController < ApplicationController
disallow_account_scope
allow_unauthenticated_access
wrap_parameters :agent_bootstrap, include: %i[ email_address name profile_name ]

def create
bootstrap = Board::AgentBootstrap.find_by!(token: params.expect(:token))
claim = bootstrap.claim!(**claim_params.to_h.symbolize_keys)

render json: {
token: claim[:access_token].token,
permission: claim[:access_token].permission,
account: {
id: bootstrap.account.id,
name: bootstrap.account.name,
slug: bootstrap.account.slug
},
board: {
id: bootstrap.board.id,
name: bootstrap.board.name,
url: board_url(bootstrap.board, script_name: bootstrap.account.slug)
},
user: {
id: claim[:user].id,
name: claim[:user].name,
email_address: claim[:identity].email_address
},
profile: {
base_url: root_url(script_name: nil).delete_suffix("/"),
account_slug: bootstrap.account.slug,
default_board_id: bootstrap.board.id
}
}, status: :created
rescue ActiveRecord::RecordNotFound
head :gone
rescue ActiveRecord::RecordInvalid => error
render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity
end

private
def claim_params
params.expect(agent_bootstrap: %i[ email_address name profile_name ])
.with_defaults(profile_name: nil)
end
end
21 changes: 21 additions & 0 deletions app/controllers/agent_bootstrap_skills_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class AgentBootstrapSkillsController < ApplicationController
disallow_account_scope
allow_unauthenticated_access

def show
bootstrap = Board::AgentBootstrap.find_by!(token: params.expect(:token))
raise ActiveRecord::RecordNotFound unless bootstrap.claimable?

send_data skill_source,
filename: "fizzy-cli.SKILL.md",
type: "text/markdown; charset=utf-8",
disposition: "inline"
rescue ActiveRecord::RecordNotFound
head :gone
end

private
def skill_source
Rails.root.join("skills", "fizzy-cli", "SKILL.md").read
end
end
36 changes: 36 additions & 0 deletions app/controllers/boards/agent_bootstraps_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class Boards::AgentBootstrapsController < ApplicationController
include BoardScoped

before_action :ensure_admin
before_action :set_agent_bootstrap, only: :show

def new
end

def show
end

def create
@agent_bootstrap = @board.agent_bootstraps.create!(
account: @board.account,
creator: Current.user,
expires_at: expires_in_minutes.minutes.from_now,
permission: params.fetch(:permission, :write),
involvement: params.fetch(:involvement, :watching)
)

respond_to do |format|
format.html { redirect_to board_agent_bootstrap_path(@board, @agent_bootstrap) }
format.json { render :show, status: :created, location: board_agent_bootstrap_url(@board, @agent_bootstrap, format: :json) }
end
end

private
def set_agent_bootstrap
@agent_bootstrap = @board.agent_bootstraps.find(params[:id])
end

def expires_in_minutes
params.fetch(:expires_in_minutes, 30).to_i.clamp(5, 24.hours.in_minutes)
end
end
43 changes: 43 additions & 0 deletions app/helpers/agent_bootstraps_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
module AgentBootstrapsHelper
AGENT_BOOTSTRAP_SKILL_NAME = "fizzy-cli"

def link_to_agent_bootstrap(board)
link_to new_board_agent_bootstrap_path(board),
class: "btn btn--circle-mobile",
data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Agent setup" } do
icon_tag("settings") + tag.span("Agent setup", class: "for-screen-reader")
end
end

def agent_bootstrap_claim_url_for(agent_bootstrap)
agent_bootstrap_claim_url(token: agent_bootstrap.token, script_name: nil)
end

def agent_bootstrap_skill_url_for(agent_bootstrap)
agent_bootstrap_skill_url(token: agent_bootstrap.token, script_name: nil)
end

def agent_bootstrap_setup_command(agent_bootstrap)
suggested_email = "agent+#{agent_bootstrap.board.id.first(8)}@example.com"
suggested_name = "#{agent_bootstrap.board.name} Agent"

%(fizzy auth bootstrap "#{agent_bootstrap_claim_url_for(agent_bootstrap)}" --email "#{suggested_email}" --name "#{suggested_name}")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Escape board name before embedding shell bootstrap command

The generated setup command injects board.name directly into a double-quoted shell argument, which allows shell expansion/substitution ($(...), backticks, $VAR, embedded quotes) when an operator copies and runs it. Because board names are user-controlled text, this can execute unintended commands or corrupt the bootstrap command; shell-escape every interpolated argument before rendering command text.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The command generation stays on Shellwords.shelljoin, which is doing the escaping here. I also added coverage that uses a hostile board name and asserts the copied command splits back into the intended argv, so this path is now verified rather than only assumed.

end
Comment on lines +22 to +31
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated setup command interpolates suggested_email/suggested_name directly into a shell command inside double quotes. If the board name contains quotes, backticks, $(), etc., the copy/paste command can break or be unsafe. Consider generating the command using proper shell escaping (e.g., Shellwords.shellescape) for each argument value.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the setup command on Shellwords.shelljoin, which is the escaping boundary here, and added controller coverage that round-trips a hostile board name through Shellwords.split to confirm the argv stays intact. The helper now only builds the argv pieces; it does not hand-roll a quoted shell string.

Comment on lines +22 to +31
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent_bootstrap.board.id is a UUID/string, so calling .first(8) will raise NoMethodError (String doesn’t implement first). Use agent_bootstrap.board.id.to_s[0, 8] (or similar) to build the suggested email prefix.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The suggested email is no longer derived from board.id.first(8). It now uses a bootstrap-token-derived suffix, which both avoids the helper bug and ensures each generated setup command gets a fresh default email.


def agent_bootstrap_skill_name
AGENT_BOOTSTRAP_SKILL_NAME
end

def agent_bootstrap_skill_block(agent_bootstrap)
<<~TEXT.strip
Download the Fizzy CLI skill from:
#{agent_bootstrap_skill_url_for(agent_bootstrap)}

Load that skill into your agent, then run:
#{agent_bootstrap_setup_command(agent_bootstrap)}

Verify the bootstrap with:
fizzy whoami --json
TEXT
end
end
1 change: 1 addition & 0 deletions app/models/board.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Board < ApplicationRecord

has_many :tags, -> { distinct }, through: :cards
has_many :events
has_many :agent_bootstraps, class_name: "Board::AgentBootstrap", dependent: :destroy
has_many :webhooks, dependent: :destroy

scope :alphabetically, -> { order("lower(name)") }
Expand Down
63 changes: 63 additions & 0 deletions app/models/board/agent_bootstrap.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
class Board::AgentBootstrap < ApplicationRecord
belongs_to :account
belongs_to :board
belongs_to :creator, class_name: "User"
belongs_to :claimed_by_identity, class_name: "Identity", optional: true

has_secure_token

enum :permission, %w[ read write ].index_by(&:itself), default: :write
enum :involvement, Access.involvements, default: :watching

scope :active, -> { where(claimed_at: nil, expires_at: Time.current...) }

validates :expires_at, presence: true

def expired?
expires_at <= Time.current
end

def claimed?
claimed_at.present?
end

def claimable?
!claimed? && !expired?
end

def claim!(email_address:, name:, profile_name: nil)
raise ActiveRecord::RecordNotFound unless claimable?

transaction do
lock!
raise ActiveRecord::RecordNotFound unless claimable?

identity = Identity.find_or_initialize_by(email_address: email_address)
identity.save! if identity.new_record?
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent claiming bootstraps as an existing identity

This claim flow looks up Identity by raw email and reuses it before minting a new access token, so the unauthenticated /agent_bootstrap/:token/claim endpoint can be used to impersonate any existing user whose email is known. In practice, anyone holding a valid bootstrap token can claim as an owner/admin identity and receive a bearer token for that identity (which is not board-scoped), enabling access beyond the intended board bootstrap scope.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. The unauthenticated claim flow no longer reuses arbitrary existing identities. It now reuses an identity only when that identity belongs exclusively to the same account; otherwise the claim is rejected. That keeps multi-board agent onboarding inside one account working without reopening the cross-account impersonation path.


user = identity.users.find_or_initialize_by(account: account)
if user.new_record?
user.name = name
user.role = :member
user.verified_at ||= Time.current
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New users created via a bootstrap are being marked verified immediately (user.verified_at ||= Time.current). This bypasses the normal verification/magic-link flow and makes the bootstrap token the only gate. If this is intended for agents, consider using a separate “service/agent” identity type or a distinct verification state so this doesn’t implicitly confer “verified human” semantics elsewhere in the app.

Suggested change
user.verified_at ||= Time.current

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the implicit verified_at write from bootstrap claims. Bootstrap-created users now stay unverified as humans, and I added a regression test showing the returned bearer token can still access the board API in that state.

user.save!
elsif !user.active?
user.update!(identity: identity, active: true)
end

board.accesses.find_or_create_by!(user: user) do |access|
access.account = account
access.involvement = involvement
end.update!(involvement: involvement)

access_token = identity.access_tokens.create!(
description: "Fizzy CLI#{profile_name.present? ? " (#{profile_name})" : " (#{name})"}",
permission: permission
)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claim! will reuse an existing Identity purely based on email_address and then mint a new Identity::AccessToken without any proof the caller controls that email/identity. Because access tokens are identity-scoped (usable across all accounts the identity belongs to), a leaked bootstrap token could be used to mint a token for an existing identity that has access to other accounts, escalating access beyond the intended board/account. Consider restricting claims to new identities (or identities that only belong to this account), or require the caller to already be authenticated as that identity when Identity already exists/has other accounts.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in two steps, then corrected the over-tightened version. The final behavior is: reuse an existing identity only if it belongs exclusively to this same account; reject identities that belong to any other account. That preserves the valid “same agent joins another board in the same account” flow while closing the cross-account token-minting issue.


update!(claimed_at: Time.current, claimed_by_identity: identity)

{ identity:, user:, access_token: }
end
end
end
24 changes: 24 additions & 0 deletions app/views/boards/agent_bootstraps/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<% @page_title = "Agent setup" %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>

<article class="panel panel--wide center flex flex-column gap">
<header>
<h1 class="txt-large margin-none font-weight-black">Set up an agent for <%= @board.name %></h1>
<p class="margin-none txt-medium">This creates a one-time setup link for a CLI agent. The agent will join this account, get a write token, and start watching this board.</p>
</header>

<%= form_with url: board_agent_bootstraps_path(@board), method: :post, html: { class: "flex flex-column gap full-width" } do |form| %>
<%= form.hidden_field :permission, value: :write %>
<%= form.hidden_field :involvement, value: :watching %>
<%= form.hidden_field :expires_in_minutes, value: 30 %>

<button class="btn">
<span>Generate setup command</span>
</button>
<% end %>
</article>
60 changes: 60 additions & 0 deletions app/views/boards/agent_bootstraps/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<% @page_title = "Agent setup" %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>

<article class="panel panel--wide center flex flex-column gap">
<header>
<h1 class="txt-large margin-none font-weight-black">Agent setup for <%= @board.name %></h1>
<p class="margin-none txt-medium">Copy the command below into an agent session. It can be used once until <%= @agent_bootstrap.expires_at.to_fs(:long) %>.</p>
</header>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Setup command</strong>
<textarea class="input" readonly rows="4"><%= agent_bootstrap_setup_command(@agent_bootstrap) %></textarea>
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_setup_command(@agent_bootstrap)) do %>
<span>Copy setup command</span>
<% end %>
</div>
</div>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Skill download URL</strong>
<input type="text" class="input" readonly value="<%= agent_bootstrap_skill_url_for(@agent_bootstrap) %>">
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_skill_url_for(@agent_bootstrap)) do %>
<span>Copy skill URL</span>
<% end %>
</div>
<p class="txt-small txt-subtle margin-none">Give this URL to an agent so it can download the current <%= agent_bootstrap_skill_name %> skill.</p>
</div>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Agent prompt</strong>
<textarea class="input" readonly rows="6"><%= agent_bootstrap_skill_block(@agent_bootstrap) %></textarea>
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_skill_block(@agent_bootstrap)) do %>
<span>Copy agent prompt</span>
<% end %>
</div>
<p class="txt-small txt-subtle margin-none">This prompt tells the agent where to fetch the skill, how to bootstrap the CLI, and how to verify access.</p>
</div>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Raw bootstrap URL</strong>
<input type="text" class="input" readonly value="<%= agent_bootstrap_claim_url_for(@agent_bootstrap) %>">
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_claim_url_for(@agent_bootstrap)) do %>
<span>Copy bootstrap URL</span>
<% end %>
</div>
</div>

<footer class="txt-small txt-subtle">
<p class="margin-none">This link creates a write token and sets the agent to watch this board.</p>
</footer>
</article>
16 changes: 16 additions & 0 deletions app/views/boards/agent_bootstraps/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
json.id @agent_bootstrap.id
json.token @agent_bootstrap.token
json.expires_at @agent_bootstrap.expires_at.utc.iso8601
json.bootstrap_url agent_bootstrap_claim_url_for(@agent_bootstrap)
json.skill_url agent_bootstrap_skill_url_for(@agent_bootstrap)
json.setup_command agent_bootstrap_setup_command(@agent_bootstrap)
json.skill_name agent_bootstrap_skill_name
json.skill_block agent_bootstrap_skill_block(@agent_bootstrap)
json.permission @agent_bootstrap.permission
json.involvement @agent_bootstrap.involvement

json.account do
json.(@agent_bootstrap.account, :id, :name, :slug)
end

json.board @agent_bootstrap.board, partial: "boards/board", as: :board
1 change: 1 addition & 0 deletions app/views/boards/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start hide-on-native">
<%= link_to_webhooks(@board) if Current.user.admin? %>
<%= link_to_agent_bootstrap(@board) if Current.user.admin? %>
</div>

<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
Expand Down
5 changes: 5 additions & 0 deletions bin/fizzy
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env ruby

$LOAD_PATH.unshift(File.expand_path("../cli/lib", __dir__))

load File.expand_path("../cli/exe/fizzy", __dir__)
Loading
Loading