clawdforge/clients/ruby/lib/clawdforge/models.rb
Kayos 6b8bccfb8d clients/ruby: apply audit findings (b1d6e3f -> new)
- S1: Client#inspect redacts @token (default ruby inspect walks ivars);
  to_s aliased and pretty_print overridden so PP doesn't bypass it.
- S2: AppToken#inspect/#to_s/#pretty_print redact :token member; nil
  token still rendered as token=nil for list-row clarity.
- S3: validate token name vs [a-z0-9_-]+ in revoke_token and create_token;
  drops URI.encode_www_form_component path-encoding dependency.
- C1: upload_timeout_secs parameter on upload_file (default 60), decoupled
  from default_timeout/http_timeout_margin so big uploads aren't capped
  by the run-subprocess timeout.
- Q6: clearer multipart filename escape via gsub block form.
- C7: dropped unused @uri ivar.
- A3: YARD note clarifying http_client: bypasses base_url host/port
  routing.

Test gaps closed: Client/AppToken inspect+pp redaction, AppToken nil-token
inspect, revoke_token name validation (path traversal, uppercase, empty,
valid), create_token name validation, upload_timeout_secs independence
from default_timeout (incl. default==60), Array-form ip_cidrs round-trip,
non-JSON 5xx error body kept as String, empty 200 body raises Error.

35 runs / 104 assertions / 0 failures.

Audit: memory/clawdforge-audits/ruby-b1d6e3f.md
2026-04-28 23:07:49 -07:00

85 lines
2.8 KiB
Ruby

# frozen_string_literal: true
module Clawdforge
# Result of a successful `POST /run`.
#
# `result` is whatever clawdforge parsed out of `claude -p --output-format
# json`: a Hash if the model returned valid JSON, otherwise the raw String.
# `ok` is always true for a RunResult — failures raise APIError instead.
RunResult = Struct.new(:ok, :result, :duration_ms, :stop_reason, keyword_init: true) do
def self.from_response(payload)
new(
ok: payload.fetch("ok", true) ? true : false,
result: payload["result"],
duration_ms: payload.fetch("duration_ms", 0).to_i,
stop_reason: payload["stop_reason"],
)
end
end
# Result of a successful `POST /files`. Pass `file_token` to
# `Client#run(files: [...])` to attach the upload to a prompt.
FileToken = Struct.new(:file_token, :ttl_secs, :size, keyword_init: true) do
def self.from_response(payload)
new(
file_token: payload.fetch("file_token"),
ttl_secs: payload.fetch("ttl_secs").to_i,
size: payload.fetch("size").to_i,
)
end
end
# A row from `GET /admin/tokens` or the result of `POST /admin/tokens`.
# `token` is the plaintext bearer string, returned ONLY at create time.
# On list responses it is nil; the server stores only a sha256 hash.
AppToken = Struct.new(
:name, :token, :ip_cidrs, :created_at, :last_used, :enabled,
keyword_init: true,
) do
def self.from_create_response(payload)
new(
name: payload.fetch("name"),
token: payload["token"],
ip_cidrs: Array(payload["ip_cidrs"]),
created_at: nil,
last_used: nil,
enabled: true,
)
end
def self.from_list_row(row)
raw = row["ip_cidrs"]
cidrs =
case raw
when String then raw.split(",").reject(&:empty?)
when Array then raw.dup
else []
end
new(
name: row.fetch("name"),
token: nil,
ip_cidrs: cidrs,
created_at: row["created_at"],
last_used: row["last_used"],
enabled: row.fetch("enabled", 1).to_i != 0,
)
end
# Override the Struct-autogenerated `inspect`/`to_s`, which would dump
# every member including the plaintext `:token` from create-time
# responses. We still want to see whether a token field is *present* —
# useful when distinguishing create-response rows from list rows —
# without leaking the bearer itself.
def inspect
tok_state = self[:token].nil? ? "nil" : "[REDACTED]"
"#<#{self.class} name=#{self[:name].inspect} token=#{tok_state} " \
"ip_cidrs=#{self[:ip_cidrs].inspect} created_at=#{self[:created_at].inspect} " \
"last_used=#{self[:last_used].inspect} enabled=#{self[:enabled].inspect}>"
end
alias_method :to_s, :inspect
def pretty_print(pp)
pp.text(inspect)
end
end
end