Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit d655138

Browse files
committed
start implementing artifact security
1 parent 527ab2b commit d655138

File tree

8 files changed

+155
-7
lines changed

8 files changed

+155
-7
lines changed

app/controllers/discourse_ai/ai_bot/artifacts_controller.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@ module DiscourseAi
44
module AiBot
55
class ArtifactsController < ApplicationController
66
requires_plugin DiscourseAi::PLUGIN_NAME
7+
before_action :require_site_settings!
78

89
skip_before_action :preload_json, :check_xhr, only: %i[show]
910

1011
def show
1112
artifact = AiArtifact.find(params[:id])
1213

1314
post = Post.find_by(id: artifact.post_id)
14-
raise Discourse::NotFound unless post && guardian.can_see?(post)
15+
if artifact.metadata&.dig("public")
16+
# no guardian needed
17+
else
18+
raise Discourse::NotFound if !post&.topic&.private_message?
19+
raise Discourse::NotFound if !guardian.can_see?(post)
20+
end
1521

1622
# Prepare the HTML document
1723
html = <<~HTML
@@ -39,6 +45,16 @@ def show
3945
# Render the content
4046
render html: html.html_safe, layout: false, content_type: "text/html"
4147
end
48+
49+
private
50+
51+
def require_site_settings!
52+
if !SiteSetting.discourse_ai_enabled ||
53+
!SiteSetting.ai_artifact_security.in?(%w[lax strict])
54+
raise Discourse::NotFound
55+
end
56+
end
57+
4258
end
4359
end
4460
end

app/models/ai_artifact.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@
33
class AiArtifact < ActiveRecord::Base
44
belongs_to :user
55
belongs_to :post
6+
7+
def self.iframe_for(id)
8+
<<~HTML
9+
<iframe sandbox="allow-scripts allow-forms" height="600px" src='#{url(id)}' frameborder="0" width="100%"></iframe>
10+
HTML
11+
end
12+
13+
def self.url(id)
14+
Discourse.base_url + "/discourse-ai/ai-bot/artifacts/#{id}"
15+
end
16+
17+
def self.share_publicly(id:, post:)
18+
artifact = AiArtifact.find_by(id: id)
19+
if artifact&.post&.topic&.id == post.topic.id
20+
artifact.update!(metadata: { public: true })
21+
end
22+
end
23+
24+
def self.unshare_publicly(id:)
25+
artifact = AiArtifact.find_by(id: id)
26+
artifact&.update!(metadata: { public: false })
27+
end
28+
29+
def url
30+
self.class.url(id)
31+
end
632
end
733

834
# == Schema Information

app/models/shared_ai_conversation.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ def self.share_conversation(user, target, max_posts: DEFAULT_MAX_POSTS)
3434

3535
def self.destroy_conversation(conversation)
3636
conversation.destroy
37+
38+
maybe_topic = conversation.target
39+
if maybe_topic.is_a?(Topic)
40+
AiArtifact.where(post: maybe_topic.posts).update_all(metadata: { public: false })
41+
end
42+
3743
::Jobs.enqueue(
3844
:shared_conversation_adjust_upload_security,
3945
target_id: conversation.target_id,
@@ -165,7 +171,7 @@ def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_us
165171
id: post.id,
166172
user_id: post.user_id,
167173
created_at: post.created_at,
168-
cooked: post.cooked,
174+
cooked: cook_artifacts(post),
169175
}
170176

171177
mapped[:persona] = persona if ai_bot_participant&.id == post.user_id
@@ -175,6 +181,22 @@ def self.build_conversation_data(topic, max_posts: DEFAULT_MAX_POSTS, include_us
175181
}
176182
end
177183

184+
def self.cook_artifacts(post)
185+
html = post.cooked
186+
return html if !["lax", "strict"].include?(SiteSetting.ai_artifact_security)
187+
188+
doc = Nokogiri::HTML5.fragment(html)
189+
doc.css("div.ai-artifact").each do |node|
190+
id = node["data-ai-artifact-id"].to_i
191+
if id > 0
192+
AiArtifact.share_publicly(id: id, post: post)
193+
node.replace(AiArtifact.iframe_for(id))
194+
end
195+
end
196+
197+
doc.to_s
198+
end
199+
178200
private
179201

180202
def populate_user_info!(posts)

assets/stylesheets/modules/ai-bot/common/ai-artifact.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ html.ai-artifact-expanded {
8686
position: fixed;
8787
top: 0;
8888
height: 100%;
89+
max-height: 100%;
8990
left: 0;
9091
right: 0;
9192
bottom: 0;

config/locales/server.en.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ en:
1717
description: "Periodic report based on a large language model"
1818
site_settings:
1919
discourse_ai_enabled: "Enable the discourse AI plugin."
20+
ai_artifact_security: "The AI artifact system generates IFRAMEs with runnable code. Strict mode disables sharing and forces an extra click to run code. Lax mode allows sharing of artifacts and runs code directly. Disabled mode disables the artifact system."
2021
ai_toxicity_enabled: "Enable the toxicity module."
2122
ai_toxicity_inference_service_api_endpoint: "URL where the API is running for the toxicity module"
2223
ai_toxicity_inference_service_api_key: "API key for the toxicity API"
@@ -79,7 +80,7 @@ en:
7980
ai_embeddings_semantic_related_include_closed_topics: "Include closed topics in semantic search results"
8081
ai_embeddings_semantic_search_hyde_model: "Model used to expand keywords to get better results during a semantic search"
8182
ai_embeddings_per_post_enabled: Generate embeddings for each post
82-
83+
8384
ai_summarization_enabled: "Enable the topic summarization module."
8485
ai_summarization_model: "Model to use for summarization."
8586
ai_custom_summarization_allowed_groups: "Groups allowed to use create new summaries."

config/settings.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ discourse_ai:
22
discourse_ai_enabled:
33
default: true
44
client: true
5-
5+
ai_artifact_security:
6+
type: enum
7+
default: "strict"
8+
choices:
9+
- "disabled"
10+
- "lax"
11+
- "strict"
612
ai_toxicity_enabled:
713
default: false
814
client: true

lib/completions/dialects/dialect.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def system_msg(msg)
168168
raise NotImplemented
169169
end
170170

171-
def assistant_msg(msg)
171+
def model_msg(msg)
172172
raise NotImplemented
173173
end
174174

@@ -177,11 +177,15 @@ def user_msg(msg)
177177
end
178178

179179
def tool_call_msg(msg)
180-
{ role: "assistant", content: tools_dialect.from_raw_tool_call(msg) }
180+
new_content = tools_dialect.from_raw_tool_call(msg)
181+
msg = msg.merge(content: new_content)
182+
model_msg(msg)
181183
end
182184

183185
def tool_msg(msg)
184-
{ role: "user", content: tools_dialect.from_raw_tool(msg) }
186+
new_content = tools_dialect.from_raw_tool(msg)
187+
msg = msg.merge(content: new_content)
188+
user_msg(msg)
185189
end
186190
end
187191
end

spec/requests/ai_bot/shared_ai_conversations_spec.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,78 @@ def share_error(key)
8686
expect(response).to have_http_status(:success)
8787
end
8888

89+
context "when ai artifacts are in lax mode" do
90+
before do
91+
SiteSetting.ai_artifact_security = "lax"
92+
end
93+
94+
it "properly shares artifacts" do
95+
first_post = user_pm_share.posts.first
96+
97+
artifact_not_allowed = AiArtifact.create!(
98+
user: bot_user,
99+
post: Fabricate(:private_message_post),
100+
name: "test",
101+
html: "<div>test</div>",
102+
)
103+
104+
artifact = AiArtifact.create!(
105+
user: bot_user,
106+
post: first_post,
107+
name: "test",
108+
html: "<div>test</div>",
109+
)
110+
111+
# lets log out and see we can not access the artifacts
112+
delete "/session/#{user.id}"
113+
114+
get artifact.url
115+
expect(response).to have_http_status(:not_found)
116+
117+
get artifact_not_allowed.url
118+
expect(response).to have_http_status(:not_found)
119+
120+
sign_in(user)
121+
122+
first_post.update!(raw: <<~RAW)
123+
This is a post with an artifact
124+
125+
<div class="ai-artifact" data-ai-artifact-id="#{artifact.id}"></div>
126+
<div class="ai-artifact" data-ai-artifact-id="#{artifact_not_allowed.id}"></div>
127+
RAW
128+
129+
post "#{path}.json", params: { topic_id: user_pm_share.id }
130+
expect(response).to have_http_status(:success)
131+
132+
key = response.parsed_body["share_key"]
133+
134+
get "#{path}/#{key}"
135+
expect(response).to have_http_status(:success)
136+
137+
138+
expect(response.body).to include(artifact.url)
139+
expect(response.body).to include(artifact_not_allowed.url)
140+
141+
# lets log out and see we can not access the artifacts
142+
delete "/session/#{user.id}"
143+
144+
get artifact.url
145+
expect(response).to have_http_status(:success)
146+
147+
get artifact_not_allowed.url
148+
expect(response).to have_http_status(:not_found)
149+
150+
sign_in(user)
151+
delete "#{path}/#{key}.json"
152+
expect(response).to have_http_status(:success)
153+
154+
# we can not longer see it...
155+
delete "/session/#{user.id}"
156+
get artifact.url
157+
expect(response).to have_http_status(:not_found)
158+
end
159+
end
160+
89161
context "when secure uploads are enabled" do
90162
let(:upload_1) { Fabricate(:s3_image_upload, user: bot_user, secure: true) }
91163
let(:upload_2) { Fabricate(:s3_image_upload, user: bot_user, secure: true) }

0 commit comments

Comments
 (0)