tina4ruby 3.13.39 → 3.13.40

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: 62dc42240a6abd35572f17207f42c631ebf2c36cc73b94adad4b163b3a2924a5
4
- data.tar.gz: e9ea35e0aad4bee25bc42f7ee7a37973144265ed538b5e6127e5a546278a62cf
3
+ metadata.gz: 0bb742a8cdfa909ee27c95cce703f14f678f03f93b764aea62501b4afde78e91
4
+ data.tar.gz: 3e0da501f8911d1df438ecebdab6b2f8ef67be7b66d39927672c23f089b351c4
5
5
  SHA512:
6
- metadata.gz: 58cf9b41857e5905b5eeda37a6881f812aad230a12533051d4510724222453f344ae1a0e05120e915a69450a05788dad7468eb1b99925e44a5bd8a625dca4c59
7
- data.tar.gz: 8ff5447f091aafe2ff0a06c7e23a8f7838190545ec96f99bc2329753ee0a9ed0714cc1a7293d127805efe504552293fbb6f08e3fa7651e8423998cc8939869f5
6
+ metadata.gz: 1f6998cc599c7ebae85a3a0020b8146721ca653a84f7f65635226072781aac2ad41ccec3cf3a749063960e3f1bae47c3e3eca97fe58375d6c01c0a5df7359487
7
+ data.tar.gz: ce0653713c6f9e0659b5f26a6068690db7915ccbc8cdeb87ca54c8cfe609c2b700f6e90a5f6ca90b8f6c198d33edbcea70599bb050855259504fd831f16b90f6
data/lib/tina4/auth.rb CHANGED
@@ -15,7 +15,11 @@ module Tina4
15
15
  BLANK_SECRET_WARNING =
16
16
  "Auth: TINA4_SECRET is not set — JWT signing is insecure. Set TINA4_SECRET " \
17
17
  "to a random value (e.g. `openssl rand -hex 32`) in your environment or " \
18
- ".env before serving traffic."
18
+ ".env before serving traffic. " \
19
+ "For LOCAL DEV, set TINA4_DEBUG=true and a per-machine secret is generated " \
20
+ "automatically into .env.local (gitignored). Seeing this warning means the " \
21
+ "run was NOT detected as dev - typically a container or CI without " \
22
+ "TINA4_DEBUG set, or TINA4_ENV=production."
19
23
 
20
24
  class << self
21
25
  def setup(root_dir = Dir.pwd)
@@ -644,22 +644,25 @@ module Tina4
644
644
  json_response(deps_install(body))
645
645
  when ["GET", "/__dev/api/git/status"]
646
646
  json_response(git_status_payload)
647
+ # All four MCP surfaces (REST shim + JSON-RPC + SSE) go through
648
+ # with_mcp_gate: capability (Tina4.mcp_enabled?) decides whether MCP
649
+ # runs at all (off → route behaves as unmounted, nil → RackApp 404,
650
+ # no info leak); per-request authorisation on the RAW socket peer +
651
+ # token decides whether THIS caller may use it (loopback always,
652
+ # remote only with TINA4_MCP_REMOTE + a valid TINA4_MCP_TOKEN — denied
653
+ # → 404 mcp_forbidden). The dev tools expose powerful ops (DB query,
654
+ # file read/WRITE, route listing). Mirrors the Python master gate.
647
655
  when ["GET", "/__dev/api/mcp/tools"]
648
- json_response(mcp_tools_list)
656
+ with_mcp_gate(env) { json_response(mcp_tools_list) }
649
657
  when ["POST", "/__dev/api/mcp/call"]
650
- body = read_json_body(env) || {}
651
- json_response(mcp_tool_call(body))
652
- # JSON-RPC + SSE endpoints that real MCP clients (Claude Code/Desktop)
653
- # speak. The dev tools expose powerful ops (DB query, file read/WRITE,
654
- # route listing), so beyond the dev-toolbar's TINA4_DEBUG check they are
655
- # gated on Tina4.mcp_enabled? — explicit TINA4_MCP wins on any host, else
656
- # dev auto-enable is LOCALHOST-ONLY unless TINA4_MCP_REMOTE=true. Not
657
- # enabled → falls through to the `else` (nil), so RackApp 404s it. They
658
- # share the default MCP server's tool registry with the REST shim.
658
+ with_mcp_gate(env) do
659
+ body = read_json_body(env) || {}
660
+ json_response(mcp_tool_call(body))
661
+ end
659
662
  when ["POST", "/__dev/mcp"], ["POST", "/__dev/mcp/message"]
660
- Tina4.mcp_enabled? ? mcp_jsonrpc(env) : nil
663
+ with_mcp_gate(env) { mcp_jsonrpc(env) }
661
664
  when ["GET", "/__dev/mcp/sse"]
662
- Tina4.mcp_enabled? ? mcp_sse_handshake : nil
665
+ with_mcp_gate(env) { mcp_sse_handshake }
663
666
  when ["GET", "/__dev/api/scaffold"]
664
667
  json_response(scaffold_templates)
665
668
  when ["POST", "/__dev/api/scaffold/run"]
@@ -1586,6 +1589,70 @@ module Tina4
1586
1589
  end
1587
1590
  end
1588
1591
 
1592
+ # Gate an MCP surface. Yields to the handler block only for an authorised
1593
+ # caller. Returns:
1594
+ # nil — capability off (route behaves as unmounted → 404, no
1595
+ # "MCP exists" info leak; matches PHP not mounting it)
1596
+ # mcp_forbidden — capability on but this caller is denied (explicit 404)
1597
+ # block result — authorised caller proceeds
1598
+ def with_mcp_gate(env)
1599
+ return nil unless Tina4.mcp_enabled?
1600
+ return mcp_forbidden unless mcp_request_allowed?(env)
1601
+
1602
+ yield
1603
+ end
1604
+
1605
+ # 404 payload for a disallowed MCP request (parity with Python/PHP).
1606
+ def mcp_forbidden
1607
+ [404, { "content-type" => "application/json; charset=utf-8" },
1608
+ [JSON.generate({ "error" => "MCP forbidden" })]]
1609
+ end
1610
+
1611
+ # Per-request MCP authorisation using the RAW socket peer.
1612
+ #
1613
+ # Reads env["REMOTE_ADDR"] (never X-Forwarded-For — spoofable) and the
1614
+ # token, then delegates to Tina4.request_allowed?. Loopback is always
1615
+ # allowed; a remote caller needs TINA4_MCP_REMOTE=true plus a valid
1616
+ # token. Mirrors the Python dev_admin _mcp_request_allowed.
1617
+ def mcp_request_allowed?(env)
1618
+ remote_ip = (env["REMOTE_ADDR"] || "").to_s
1619
+ Tina4.request_allowed?(remote_ip, has_valid_token: mcp_token_ok?(env))
1620
+ end
1621
+
1622
+ # Whether the request carried a token matching TINA4_MCP_TOKEN.
1623
+ #
1624
+ # Token transports (in order): Authorization: Bearer, X-MCP-Token,
1625
+ # X-Api-Key. Compared timing-safe against TINA4_MCP_TOKEN (fallback
1626
+ # TINA4_API_KEY). With NO configured token this returns false, so a
1627
+ # remote caller can never present a "valid" token by accident.
1628
+ def mcp_token_ok?(env)
1629
+ expected = ENV["TINA4_MCP_TOKEN"]
1630
+ expected = ENV["TINA4_API_KEY"] if expected.nil? || expected.empty?
1631
+ return false if expected.nil? || expected.empty?
1632
+
1633
+ provided = ""
1634
+ auth = (env["HTTP_AUTHORIZATION"] || "").to_s
1635
+ provided = auth[7..].to_s.strip if auth.downcase.start_with?("bearer ")
1636
+ provided = (env["HTTP_X_MCP_TOKEN"] || "").to_s if provided.empty?
1637
+ provided = (env["HTTP_X_API_KEY"] || "").to_s if provided.empty?
1638
+ return false if provided.empty?
1639
+
1640
+ secure_equal?(expected.to_s, provided)
1641
+ end
1642
+
1643
+ # Constant-time string comparison (length check first so the
1644
+ # fixed-length compare never raises on a mismatch).
1645
+ def secure_equal?(expected, provided)
1646
+ return false unless expected.bytesize == provided.bytesize
1647
+
1648
+ OpenSSL.fixed_length_secure_compare(expected, provided)
1649
+ rescue StandardError
1650
+ # Fallback for very old OpenSSL without fixed_length_secure_compare.
1651
+ res = 0
1652
+ expected.bytes.zip(provided.bytes) { |a, b| res |= (a ^ b.to_i) }
1653
+ res.zero?
1654
+ end
1655
+
1589
1656
  def mcp_tools_list
1590
1657
  return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
1591
1658
  server = Tina4._default_mcp_server