discourse/plugins/discourse-ai/spec/models/ai_tool_spec.rb
Sam ab51aa7d81
FEATURE: add crypto utilities to AI tool runner (#39343)
Adds a `crypto` API to the JavaScript tool runner exposing HMAC
(SHA1/SHA256), hashing (MD5/SHA1/SHA256), RSA PKCS1v15 signing, base64
and base64url encoding, and cryptographically secure random bytes. All
functions are synchronous bridges to Ruby's OpenSSL with string or
Uint8Array inputs and a 10MB per-call limit, enabling webhook signature
verification, JWT signing for service accounts, and similar use cases.

Also splits the monolithic `tool_runner.rb` into per-feature modules
(`http`, `llm`, `index`, `upload`, `discourse`, `crypto`) with matching
spec files, and documents the discourse API's security model in the
tool preamble so authors understand the privilege boundaries when
non-admins trigger their tools.


New interface allows for stuff like this in a tool: 

```
function signJWT(cred) {
  const now = Math.floor(Date.now() / 1000);
  const hdr = crypto.base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
  const pay = crypto.base64UrlEncode(JSON.stringify({
    iss: cred.client_email,
    scope: "https://www.googleapis.com/auth/bigquery.readonly",
    aud: "https://oauth2.googleapis.com/token",
    iat: now,
    exp: now + 3600,
  }));
  const msg = hdr + "." + pay;
  return msg + "." + crypto.base64UrlEncode(crypto.signRsaSha256(cred.private_key, msg));
}

function getToken(cred) {
  const jwt = signJWT(cred);
  const r = http.post("https://oauth2.googleapis.com/token", {
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body:
      "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" + jwt,
  });
  if (r.status !== 200) throw new Error("OAuth2 failed (" + r.status + "): " + r.body);
  return JSON.parse(r.body).access_token;
}

function invoke(params) {
  const cred = JSON.parse(secrets.get("json_cred"));
  const token = getToken(cred);
  const project = cred.project_id;

  const url =
    "https://bigquery.googleapis.com/bigquery/v2/projects/" +
    encodeURIComponent(project) +
    "/datasets";
  const r = http.get(url, { headers: { Authorization: "Bearer " + token } });
  if (r.status !== 200) throw new Error("BigQuery (" + r.status + "): " + r.body);

  const body = JSON.parse(r.body);
  const datasets = (body.datasets || []).map(function (ds) {
    return ds.datasetReference.datasetId;
  });

  return { project: project, datasets: datasets };
}

function details() {
  return "Lists all BigQuery datasets in the project";
}
```

Previously to achieve the same thing we would need to implement crypto
direct in JS
2026-04-21 14:25:18 +10:00

193 lines
6 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe AiTool do
fab!(:llm_model) { Fabricate(:llm_model, name: "claude-2") }
let(:llm) { DiscourseAi::Completions::Llm.proxy(llm_model) }
fab!(:bot_user) { Discourse.system_user }
def create_tool(
parameters: nil,
script: nil,
secret_contracts: nil,
rag_chunk_tokens: nil,
rag_chunk_overlap_tokens: nil
)
AiTool.create!(
name: "test #{SecureRandom.uuid}",
tool_name: "test_#{SecureRandom.uuid.underscore}",
description: "test",
parameters:
parameters || [{ name: "query", type: "string", description: "perform a search" }],
script: script || "function invoke(params) { return params; }",
secret_contracts: secret_contracts || [],
created_by_id: 1,
summary: "Test tool summary",
rag_chunk_tokens: rag_chunk_tokens || 374,
rag_chunk_overlap_tokens: rag_chunk_overlap_tokens || 10,
)
end
before { enable_current_plugin }
it "it can run a basic tool" do
tool = create_tool
expect(tool.signature).to eq(
{
name: tool.tool_name,
description: "test",
parameters: [{ name: "query", type: "string", description: "perform a search" }],
},
)
runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil)
expect(runner.invoke).to eq("query" => "test")
end
it "validates secret contracts" do
tool =
create_tool(
parameters: [{ name: "query", type: "string", description: "perform a search" }],
script: "function invoke(params) { return params; }",
)
tool.secret_contracts = [{ alias: "invalid alias" }, { alias: "invalid alias" }]
expect(tool).not_to be_valid
expect(tool.errors[:secret_contracts]).to be_present
end
it "can replace and resolve secret bindings by alias" do
tool = create_tool(secret_contracts: [{ alias: "weather_api_key" }])
secret = Fabricate(:ai_secret)
tool.replace_secret_bindings!([{ alias: "weather_api_key", ai_secret_id: secret.id }])
value, error = tool.resolve_secret("weather_api_key")
expect(error).to be_nil
expect(value).to eq(secret.secret)
expect(tool.missing_secret_aliases).to eq([])
end
it "can timeout slow JS" do
script = <<~JS
function invoke(params) {
while (true) {}
}
JS
tool = create_tool(script: script)
runner = tool.runner({ "query" => "test" }, llm: nil, bot_user: nil)
runner.timeout = 5
result = runner.invoke
expect(result[:error]).to eq("Script terminated due to timeout")
end
it "can use sleep function with limits" do
script = <<~JS
function invoke(params) {
let results = [];
for (let i = 0; i < 3; i++) {
let result = sleep(1); // 1ms sleep
results.push(result);
}
return results;
}
JS
tool = create_tool(script: script)
runner = tool.runner({}, llm: nil, bot_user: nil)
result = runner.invoke
expect(result).to eq([{ "slept" => 1 }, { "slept" => 1 }, { "slept" => 1 }])
end
describe "#set_image_generation_tool_flag" do
it "sets flag to true when tool has all required characteristics" do
tool =
create_tool(parameters: [{ name: "prompt", type: "string", required: true }], script: <<~JS)
function invoke(params) {
const image = upload.create("test.png", "base64data");
chain.setCustomRaw(`![test](${image.short_url})`);
return { result: "success" };
}
JS
expect(tool.is_image_generation_tool).to eq(true)
end
it "sets flag to false when tool missing prompt parameter" do
tool =
create_tool(parameters: [{ name: "query", type: "string", required: true }], script: <<~JS)
function invoke(params) {
const image = upload.create("test.png", "base64data");
chain.setCustomRaw(`![test](${image.short_url})`);
return { result: "success" };
}
JS
expect(tool.is_image_generation_tool).to eq(false)
end
it "sets flag to false when tool missing upload.create" do
tool =
create_tool(parameters: [{ name: "prompt", type: "string", required: true }], script: <<~JS)
function invoke(params) {
chain.setCustomRaw(`![test](upload://test123)`);
return { result: "success" };
}
JS
expect(tool.is_image_generation_tool).to eq(false)
end
it "sets flag to false when tool missing chain.setCustomRaw" do
tool =
create_tool(parameters: [{ name: "prompt", type: "string", required: true }], script: <<~JS)
function invoke(params) {
const image = upload.create("test.png", "base64data");
return { result: "success", image: image };
}
JS
expect(tool.is_image_generation_tool).to eq(false)
end
it "updates flag when tool is updated" do
tool =
create_tool(
parameters: [{ name: "query", type: "string", required: true }],
script: "function invoke(params) { return params; }",
)
expect(tool.is_image_generation_tool).to eq(false)
tool.update!(parameters: [{ name: "prompt", type: "string", required: true }], script: <<~JS)
function invoke(params) {
const image = upload.create("test.png", "base64data");
chain.setCustomRaw(`![test](${image.short_url})`);
return { result: "success" };
}
JS
expect(tool.is_image_generation_tool).to eq(true)
end
it "handles edge case with spaces in method calls" do
tool =
create_tool(parameters: [{ name: "prompt", type: "string", required: true }], script: <<~JS)
function invoke(params) {
const image = upload . create("test.png", "base64data");
chain . setCustomRaw(`![test](${image.short_url})`);
return { result: "success" };
}
JS
expect(tool.is_image_generation_tool).to eq(false)
end
end
end