silas 0.6.0 → 0.6.1

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: fd68f6d368466b20fbb35f49628f166ac61dd60ace3a8cbd20bbc6e316c1d346
4
- data.tar.gz: 94d2a902cd7f364aa33200b1ea1f98446dabee38d9e9f7431df9cd96d1fc7f41
3
+ metadata.gz: d6fb5d6a2e04f91a4bc0ac0569a37da43017f6adae7209148878a2cb8ac41a66
4
+ data.tar.gz: 9d8ed143aac8d96d264a10ed3cf87c8ecee1ed838ba01ca67075a4a9a07e3b09
5
5
  SHA512:
6
- metadata.gz: c386608b076c2c3dfb23e369c819a7d7b1c4fc6122f0011ea80880a46b158b25549a75388a17de250b200a60e0ada311a73df28b47c67e4007cc9e2ebef8bf3f
7
- data.tar.gz: 4216b83c6925605076cb3742286699673e2a18dfd9eab3a3cc6e21479ee64e17de714737e5764acce0f147ca0023ced60ed0864b0836850c771dbab88ea84100
6
+ metadata.gz: e01f7110a093b91b30af1c08d4d8167aaaa24861812702c10ba5078f952e7eea4ddb5851e484f6cb2815d171271de9a45d8b7cc275e3d59dcaa2541ad9731628
7
+ data.tar.gz: c84142c165b45db56d5761a64a62993e7332a2209593ff1c59afd58723beafc5e2bd2c14f1495fbb8963d664a69469f28829dece7d6e973e8a5e123963ca526b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 (2026-07-26)
4
+
5
+ Two live-inbox fixes found by driving real apps, a security hardening pass,
6
+ and the polished brand kit.
7
+
8
+ ### Changed
9
+
10
+ - **Polished brand geometry everywhere.** Lamps sit on a shared r4.8 circle
11
+ (proceed and held aspects now one geometry), the favicon is redrawn
12
+ natively at 32px with a heavier housing (it reads at 16px now), and the
13
+ wordmark lockup is cropped to the ink — re-outlined to font-independent
14
+ paths on the new geometry. The inbox layout's favicon and header mark
15
+ carry the new coordinates, as do the templates' landing pages.
16
+ - The docs gained real product imagery: an 18-frame park→approve→clear GIF
17
+ captured from a genuine template-app run, the held approval card, and the
18
+ signal board — on the site landing, inbox-and-api, and tutorial pages.
19
+
20
+ ### Fixed
21
+
22
+ - **The turn pill now flips to held live.** Turbo `replace` swaps the target
23
+ element itself, but the header's and cost line's broadcast-target ids lived
24
+ on wrapper divs in the parent views — so the FIRST status broadcast
25
+ destroyed the target and every later one (including the park that reads
26
+ **held**) silently no-opped until a reload. The partials now carry their own
27
+ root ids, with regression specs asserting a replace re-emits its target.
28
+ - **The first broadcast of a fresh worker no longer dies on lazy routes.**
29
+ Rails 8.1's lazy route set only retries a missing url helper when the app
30
+ routes were *just* loaded; in a worker's first Turbo render the engine's
31
+ helper module could predate its route draw, killing the job silently
32
+ (`undefined method 'cancel_inbox_turn_path'`). `silas_engine_path` now
33
+ forces the draw once on miss.
34
+
35
+ ### Security
36
+
37
+ - **Connections refuse credentials over plaintext http.** A connection with
38
+ `auth:` configured and an `http://` URL to a remote host now fails loudly at
39
+ parse time (localhost exempt; unparseable URLs with auth fail closed).
40
+ Finding from the pre-release security audit — which otherwise confirmed the
41
+ posture: fail-closed HMAC webhook verification with constant-time compare
42
+ and a replay window, expiring signed approval tokens, deny-by-default
43
+ inbox/API auth enforced at the base controllers, argv-array sandbox exec
44
+ (no shell interpolation), timing-safe MCP token compare, restricted ERB
45
+ bindings, no interpolated SQL, Brakeman and bundler-audit clean.
46
+
3
47
  ## 0.6.0 (2026-07-26)
4
48
 
5
49
  The brand release: the Signals inbox, the docs surface (site + gem-shipped
@@ -65,7 +65,15 @@ module Silas
65
65
  # bare broadcast renderer doesn't have — so broadcast-rendered partials
66
66
  # build paths from the engine's own route set + the discovered mount.
67
67
  def silas_engine_path(helper, *args)
68
- Silas::Engine.routes.url_helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
68
+ helpers = Silas::Engine.routes.url_helpers
69
+ # Rails 8.1's lazy route set: in a worker's FIRST broadcast render the
70
+ # engine's helper module can predate its route draw, and the lazy
71
+ # method_missing only retries when the app routes were *just* loaded —
72
+ # otherwise it raises NoMethodError and the Turbo job dies silently
73
+ # (observed: the first turn broadcast of a fresh worker). Force the
74
+ # draw once on miss; every later call takes the fast path.
75
+ Rails.application.reload_routes! unless helpers.respond_to?(helper)
76
+ helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
69
77
  end
70
78
  end
71
79
  end
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title><%= content_for?(:title) ? yield(:title) : "Silas Inbox" %></title>
7
7
  <%# The night tile, inlined — the engine ships no assets and keeps shipping none. %>
8
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230F1013'/%3E%3Cg transform='translate(4,4)'%3E%3Ccircle cx='12' cy='12' r='9.6' fill='none' stroke='%23E9EBEF' stroke-width='2.2'/%3E%3Ccircle cx='8.4' cy='15.6' r='2.5' fill='%23F2F4F8'/%3E%3Ccircle cx='15.6' cy='8.4' r='2.5' fill='%23F2F4F8'/%3E%3C/g%3E%3C/svg%3E">
8
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230F1013'/%3E%3Ccircle cx='16' cy='16' r='10.8' fill='none' stroke='%23E9EBEF' stroke-width='2.5'/%3E%3Ccircle cx='12.18' cy='19.82' r='2.8' fill='%23F2F4F8'/%3E%3Ccircle cx='19.82' cy='12.18' r='2.8' fill='%23F2F4F8'/%3E%3C/svg%3E">
9
9
  <% if Silas::Inbox.streaming_available? %><%= turbo_include_tags rescue nil %><% end %>
10
10
  <style>
11
11
  /* Direction "Signals" (see docs/conventions.md and the brand book).
@@ -160,7 +160,7 @@
160
160
  <%# The mark: a position-light signal, diagonal = "proceed". Bezel in
161
161
  ink, lamps in the accent — so the lamps are white at night and
162
162
  route-blue on paper, per the one-token-may-change-hue rule. %>
163
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" role="img" aria-hidden="true"><circle cx="12" cy="12" r="9.6" fill="none" style="stroke: var(--ink)" stroke-width="2.2"/><circle cx="8.4" cy="15.6" r="2.5" style="fill: var(--accent)"/><circle cx="15.6" cy="8.4" r="2.5" style="fill: var(--accent)"/></svg><span>silas</span>
163
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" role="img" aria-hidden="true"><circle cx="12" cy="12" r="9.6" fill="none" style="stroke: var(--ink)" stroke-width="2.2"/><circle cx="8.61" cy="15.39" r="2.5" style="fill: var(--accent)"/><circle cx="15.39" cy="8.61" r="2.5" style="fill: var(--accent)"/></svg><span>silas</span>
164
164
  <% end %>
165
165
  </span>
166
166
  <span class="spacer"></span>
@@ -1 +1,3 @@
1
- <span class="cost"><%= cost_label(Silas::Inbox::Cost.for_session(session)) %></span>
1
+ <%# Root id = the broadcast target (replaced after every completed step); it
2
+ must be in the partial or the first replace erases the target. %>
3
+ <span class="cost" id="silas-session-<%= session.id %>-cost"><%= cost_label(Silas::Inbox::Cost.for_session(session)) %></span>
@@ -12,7 +12,7 @@
12
12
  <span class="muted">#<%= @session.id %></span>
13
13
  </div>
14
14
  <div class="muted"><%= @session.channel.presence || "direct" %> · started <%= silas_relative_time(@session.created_at) %></div>
15
- <div id="silas-session-<%= @session.id %>-cost"><%= render "silas/inbox/sessions/cost", session: @session %></div>
15
+ <%= render "silas/inbox/sessions/cost", session: @session %>
16
16
  </div>
17
17
 
18
18
  <%# 04: everything awaiting a person sits ABOVE the movement — the operator
@@ -1,28 +1,34 @@
1
- <div class="turn-head">
2
- <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
3
- <%= status_pill(turn.status) %>
4
- <% if turn.active? && !turn.cancel_requested_at %>
5
- <%# silas_engine_path — this partial is broadcast-rendered through the
6
- host renderer on every turn status change; bare engine helpers (and
7
- the scope-dependent mounted proxy) 500 there. %>
8
- <%= form_with url: silas_engine_path(:cancel_inbox_turn_path, turn), method: :post, class: "inline" do %>
9
- <button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
1
+ <%# The root id IS the broadcast target (Broadcastable replaces
2
+ silas-turn-<id>-header on every status change). It must live HERE, not on
3
+ a wrapper in the parent: Turbo `replace` swaps the target element itself,
4
+ so a partial without the id would erase the target on first use. %>
5
+ <div id="silas-turn-<%= turn.id %>-header">
6
+ <div class="turn-head">
7
+ <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
8
+ <%= status_pill(turn.status) %>
9
+ <% if turn.active? && !turn.cancel_requested_at %>
10
+ <%# silas_engine_path — this partial is broadcast-rendered through the
11
+ host renderer on every turn status change; bare engine helpers (and
12
+ the scope-dependent mounted proxy) 500 there. %>
13
+ <%= form_with url: silas_engine_path(:cancel_inbox_turn_path, turn), method: :post, class: "inline" do %>
14
+ <button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
15
+ <% end %>
16
+ <% elsif turn.cancel_requested_at && turn.active? %>
17
+ <span class="muted">cancel requested…</span>
10
18
  <% end %>
11
- <% elsif turn.cancel_requested_at && turn.active? %>
12
- <span class="muted">cancel requested…</span>
19
+ </div>
20
+ <% if turn.budget_parked? %>
21
+ <div class="approval">
22
+ <h3>Budget reached — <%= turn.failure_reason %></h3>
23
+ <div class="muted">Parked at zero compute. Raise the limit and the turn resumes
24
+ by replaying completed work from rows — no re-calls, no re-effects.</div>
25
+ <%= form_with url: silas_engine_path(:raise_budget_inbox_turn_path, turn), method: :post, class: "topup-form" do %>
26
+ <input name="value" type="number" step="any" min="0" required class="topup-input"
27
+ placeholder="<%= turn.failure_reason == "max_cost" ? "new limit in dollars, e.g. 2.50" : "new limit, e.g. 200000" %>">
28
+ <button class="btn approve">Raise &amp; resume</button>
29
+ <% end %>
30
+ </div>
31
+ <% elsif turn.failure_reason.present? %>
32
+ <div class="muted">failed: <%= turn.failure_reason %></div>
13
33
  <% end %>
14
34
  </div>
15
- <% if turn.budget_parked? %>
16
- <div class="approval">
17
- <h3>Budget reached — <%= turn.failure_reason %></h3>
18
- <div class="muted">Parked at zero compute. Raise the limit and the turn resumes
19
- by replaying completed work from rows — no re-calls, no re-effects.</div>
20
- <%= form_with url: silas_engine_path(:raise_budget_inbox_turn_path, turn), method: :post, class: "topup-form" do %>
21
- <input name="value" type="number" step="any" min="0" required class="topup-input"
22
- placeholder="<%= turn.failure_reason == "max_cost" ? "new limit in dollars, e.g. 2.50" : "new limit, e.g. 200000" %>">
23
- <button class="btn approve">Raise &amp; resume</button>
24
- <% end %>
25
- </div>
26
- <% elsif turn.failure_reason.present? %>
27
- <div class="muted">failed: <%= turn.failure_reason %></div>
28
- <% end %>
@@ -1,7 +1,9 @@
1
1
  <div class="turn" id="<%= dom_id(turn) %>">
2
- <div id="silas-turn-<%= turn.id %>-header">
3
- <%= render "silas/inbox/turns/header", turn: turn %>
4
- </div>
2
+ <%# The header partial carries its own broadcast-target id: Turbo `replace`
3
+ swaps the TARGET ELEMENT itself, so if the id lived on a wrapper here,
4
+ the first status broadcast would destroy it and every later transition
5
+ (including the park that flips the pill to held) would silently no-op. %>
6
+ <%= render "silas/inbox/turns/header", turn: turn %>
5
7
  <div id="silas-turn-<%= turn.id %>-steps">
6
8
  <%= render partial: "silas/inbox/steps/step", collection: turn.steps, as: :step %>
7
9
  </div>
data/docs/connections.md CHANGED
@@ -29,6 +29,9 @@ through the same Ledger.
29
29
  join your database transaction, so the honest ceiling is `at_most_once`
30
30
  (an ambiguous crash parks in-doubt for a person) or `idempotent` (you're
31
31
  asserting the remote op is safe to repeat).
32
+ - **Credentials require https.** A connection with `auth:` configured and a
33
+ plaintext `http://` URL fails loudly at parse time (localhost is exempt for
34
+ local development servers).
32
35
  - **Transport is HTTP** (v1). Filename is the namespace: `crm.yml` →
33
36
  `crm__*`.
34
37
 
@@ -13,6 +13,13 @@ hoisted to the top of the session, a full audit trail (every tool call's
13
13
  arguments and result, who cleared what and why), cancel, raise-budget, and
14
14
  per-session token/cost accounting priced from RubyLLM's model registry.
15
15
 
16
+ <img src="https://raw.githubusercontent.com/danielstpaul/silas/main/docs/img/silas-inbox-held.png" width="740"
17
+ alt="A session held at the signal: the amber approval card for issue_refund hoisted above the trace, with the turn marked HELD and a held-at-the-signal stub in place">
18
+
19
+ A £64 refund holding at the signal: the card carries the exact arguments, the
20
+ turn costs nothing while it waits, and the trace below keeps a one-line stub
21
+ where the invocation held.
22
+
16
23
  It's **deny-by-default** — invisible until you wire auth:
17
24
 
18
25
  ```ruby
data/docs/tutorial.md CHANGED
@@ -15,8 +15,12 @@ rails new desk -m https://raw.githubusercontent.com/danielstpaul/silas/main/temp
15
15
  cd desk && bin/dev
16
16
  ```
17
17
 
18
- Open <http://localhost:3000> — the **signal board** — then *operator inbox →*
19
- and start a session:
18
+ Open <http://localhost:3000> — the **signal board**:
19
+
20
+ <img src="https://raw.githubusercontent.com/danielstpaul/silas/main/docs/img/silas-board.png" width="740"
21
+ alt="The desk's signal board: aspect tiles for held/working/clear/in doubt, the keyless demo banner, the agent card, and try-it prompts">
22
+
23
+ Then *operator inbox →* and start a session:
20
24
 
21
25
  > The walnut monitor stand (order R-1002) arrived cracked.
22
26
 
@@ -51,6 +51,22 @@ module Silas
51
51
  raise Error, "connection #{name}: transport #{transport.inspect} unsupported (v1: http)" unless TRANSPORTS.include?(transport)
52
52
  raise Error, "connection #{name}: approval #{approval.inspect} invalid" unless APPROVALS.include?(approval)
53
53
  raise Error, "connection #{name}: effect #{effect.inspect} invalid" unless EFFECTS.include?(effect)
54
+ # Never send a credential over plaintext: an auth'd connection must be
55
+ # https (loopback exempt for local development servers). Boot-time and
56
+ # loud, like every other connection misconfiguration.
57
+ if @auth["type"].present? && plaintext_remote?
58
+ raise Error, "connection #{name}: refusing to send credentials over plaintext http — " \
59
+ "use https (localhost/127.0.0.1 are exempt)"
60
+ end
61
+ end
62
+
63
+ def plaintext_remote?
64
+ uri = URI(url)
65
+ # Scheme case-insensitively (HTTP:// is still plaintext); URI#host keeps
66
+ # brackets on IPv6 literals, so the loopback exemption lists both forms.
67
+ uri.scheme&.downcase == "http" && !%w[localhost 127.0.0.1 ::1 [::1]].include?(uri.host)
68
+ rescue URI::InvalidURIError
69
+ true # an unparseable url with auth configured fails closed
54
70
  end
55
71
 
56
72
  # One remote tool, resolved. Quacks like a resolved Silas::Tool for the
data/lib/silas/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: silas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel St Paul