Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

293 linesCodeBlameRaw
1//! What of a session each viewer sees.
2//!
3//! A session records someone's own work as it happens: what they typed to
4//! their agent, the commands it ran, what came back. On a public
5//! repository anyone may read the repository, but only the workspace's
6//! members see inside its sessions; everyone else sees that one runs, who
7//! runs it and what it is tied to ([`Sight::Outside`]). Members see what it
8//! did, never the prompts someone else typed or the output a tool gave
9//! them ([`Sight::Member`]), and a title, the team's one line on it, without
10//! swearing. Whoever ran it sees it all ([`Sight::Own`]).
11//!
12//! Credentials are taken out of everything as it is recorded ([`clean`]),
13//! and again as it is read, for what was recorded before that.
14
15use g1t_contracts::Viewer;
16use g1t_contracts::work::{Session, SessionDetail, SessionEntry, SessionEntryKind, SessionUpdate};
17use g1t_scan::redact::{redact, soften};
18
19/// How much of a session a viewer sees.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub(crate) enum Sight {
22 /// Whoever ran it.
23 Own,
24 /// A member of the repository's workspace.
25 Member,
26 /// Anyone else who may read the repository.
27 Outside,
28}
29
30impl Sight {
31 pub(crate) fn of(viewer: &Viewer, namespace: &str, actor_id: &str) -> Sight {
32 match viewer {
33 Some(viewer) if viewer.id == actor_id => Sight::Own,
34 Some(viewer) if viewer.is_member(&namespace.to_lowercase()) => Sight::Member,
35 _ => Sight::Outside,
36 }
37 }
38}
39
40/// Text as it is kept: credentials out.
41pub(crate) fn clean(text: &str) -> String {
42 redact(text)
43}
44
45/// [`clean`], for a field that may be absent.
46pub(crate) fn clean_opt(text: Option<String>) -> Option<String> {
47 text.map(|text| redact(&text)).filter(|text| !text.trim().is_empty())
48}
49
50/// A session as `sight` sees it.
51pub(crate) fn session(session: &mut Session, sight: Sight) {
52 match sight {
53 Sight::Own | Sight::Member => {
54 session.title = match sight {
55 Sight::Own => clean_opt(session.title.take()),
56 _ => session.title.take().map(|title| soften(&title)).filter(|title| !title.is_empty()),
57 };
58 // Words typed by a person: theirs alone.
59 session.first_prompt = match sight {
60 Sight::Own => clean_opt(session.first_prompt.take()),
61 _ => None,
62 };
63 session.task = clean_opt(session.task.take());
64 session.prompt = clean_opt(session.prompt.take());
65 session.result = clean_opt(session.result.take());
66 session.end_reason = clean_opt(session.end_reason.take());
67 }
68 Sight::Outside => {
69 session.title = None;
70 session.first_prompt = None;
71 session.task = None;
72 session.prompt = None;
73 session.result = None;
74 session.model = None;
75 session.branch = None;
76 session.head_commit = None;
77 session.harness_session_id = None;
78 session.agent_type = None;
79 session.cost_usd = None;
80 session.cost_partial = false;
81 session.end_reason = session.end_reason.take().filter(|reason| reason == "timed_out");
82 }
83 }
84}
85
86/// An entry as `sight` sees it; `None` when it is not shown at all.
87pub(crate) fn entry(mut entry: SessionEntry, sight: Sight) -> Option<SessionEntry> {
88 match sight {
89 Sight::Outside => None,
90 Sight::Member if matches!(entry.kind, SessionEntryKind::Prompt | SessionEntryKind::ToolResult) => {
91 entry.text = String::new();
92 entry.withheld = true;
93 Some(entry)
94 }
95 _ => {
96 entry.text = redact(&entry.text);
97 Some(entry)
98 }
99 }
100}
101
102/// Every string in an update's data, credentials out.
103fn clean_value(value: &mut serde_json::Value) {
104 match value {
105 serde_json::Value::String(text) => *text = redact(text),
106 serde_json::Value::Array(items) => items.iter_mut().for_each(clean_value),
107 serde_json::Value::Object(fields) => fields.values_mut().for_each(clean_value),
108 _ => {}
109 }
110}
111
112/// What an update may say of another session that only members see.
113const MEMBERS_ONLY: &[&str] = &[
114 "title", "first_prompt", "prompt", "task", "result", "branch", "head_commit", "harness_session_id", "model", "agent_type", "end_reason",
115 "cost_usd", "costUsd",
116];
117
118/// An update's data without what only members see: what other sessions
119/// are titled, were asked, reported and run on.
120fn outside_value(value: &mut serde_json::Value) {
121 match value {
122 serde_json::Value::Array(items) => items.iter_mut().for_each(outside_value),
123 serde_json::Value::Object(fields) => {
124 for key in MEMBERS_ONLY {
125 if fields.get(*key).is_some_and(|value| !value.is_null()) {
126 fields.insert((*key).to_owned(), serde_json::Value::Null);
127 }
128 }
129 fields.values_mut().for_each(outside_value);
130 }
131 _ => {}
132 }
133}
134
135/// An update as its session's agent reads it: credentials out, and for
136/// someone outside the workspace (on a public repository, their own
137/// session hears of members' sessions) only what an outsider may see.
138fn update(mut update: SessionUpdate, member: bool) -> SessionUpdate {
139 clean_value(&mut update.data);
140 if !member {
141 outside_value(&mut update.data);
142 }
143 update
144}
145
146/// A session's page as `viewer` sees it, on a repository of `namespace`.
147pub(crate) fn detail(mut detail: SessionDetail, viewer: &Viewer, namespace: &str) -> SessionDetail {
148 let sight = Sight::of(viewer, namespace, &detail.session.actor.id);
149 session(&mut detail.session, sight);
150 detail.entries = detail.entries.into_iter().filter_map(|one| entry(one, sight)).collect();
151 for child in &mut detail.children {
152 session(child, Sight::of(viewer, namespace, &child.actor.id));
153 }
154 if let Some(root) = &mut detail.root {
155 session(root, Sight::of(viewer, namespace, &root.actor.id));
156 }
157 if sight == Sight::Outside {
158 detail.touched.clear();
159 detail.skills.clear();
160 detail.updates.clear();
161 detail.children.clear();
162 detail.limited = true;
163 } else {
164 let member = viewer.as_ref().is_some_and(|viewer| viewer.is_member(&namespace.to_lowercase()));
165 detail.updates = detail.updates.into_iter().map(|one| update(one, member)).collect();
166 }
167 detail
168}
169
170/// Sessions in a list, each as `viewer` sees it.
171pub(crate) fn sessions(sessions: &mut [Session], viewer: &Viewer, namespace: &str) {
172 for one in sessions {
173 let sight = Sight::of(viewer, namespace, &one.actor.id);
174 session(one, sight);
175 }
176}
177
178#[cfg(test)]
179pub(crate) mod tests {
180 use super::*;
181 use g1t_contracts::{Membership, User};
182 use g1t_contracts::credentials::Principal;
183 use g1t_contracts::work::{Harness, SessionState};
184
185 fn person(id: &str, workspaces: &[&str]) -> Viewer {
186 Some(User {
187 id: id.into(),
188 username: id.into(),
189 workspaces: workspaces.iter().map(|slug| Membership::member(slug.to_string())).collect(),
190 ..User::default()
191 })
192 }
193
194 pub(crate) fn a_session() -> Session {
195 Session {
196 id: "ses_1".into(),
197 repo_id: "rep_1".into(),
198 item: Some(4),
199 pull: None,
200 branch: Some("secret-plans".into()),
201 actor: Principal { id: "usr_a".into(), username: "a".into() },
202 harness: Harness::ClaudeCode,
203 harness_session_id: Some("d1485".into()),
204 title: Some("Fix this fucking login with G1T_TOKEN=abc123def456".into()),
205 state: SessionState::Active,
206 head_commit: Some("58def40".into()),
207 started_at: String::new(),
208 last_seen_at: String::new(),
209 ended_at: None,
210 end_reason: None,
211 parent_id: None,
212 agent_type: None,
213 task: None,
214 prompt: None,
215 result: Some("done; token ghp_A1b2C3d4E5f6G7h8I9j0".into()),
216 model: Some("claude-opus-5-5".into()),
217 subagents: 0,
218 first_prompt: Some("ugh fix it".into()),
219 title_source: None,
220 title_settled_at: None,
221 cost_usd: Some(4.12),
222 cost_partial: true,
223 }
224 }
225
226 fn an_entry(kind: SessionEntryKind, text: &str) -> SessionEntry {
227 SessionEntry { seq: 1, kind, text: text.into(), tool: None, commit: None, at: String::new(), withheld: false }
228 }
229
230 #[test]
231 fn who_sees_what() {
232 assert_eq!(Sight::of(&person("usr_a", &[]), "acme", "usr_a"), Sight::Own);
233 assert_eq!(Sight::of(&person("usr_b", &["acme"]), "Acme", "usr_a"), Sight::Member);
234 assert_eq!(Sight::of(&person("usr_c", &["other"]), "acme", "usr_a"), Sight::Outside);
235 assert_eq!(Sight::of(&None, "acme", "usr_a"), Sight::Outside);
236 }
237
238 #[test]
239 fn an_outsider_sees_that_it_runs_and_nothing_inside() {
240 let mut one = a_session();
241 session(&mut one, Sight::Outside);
242 assert_eq!(one.title, None);
243 assert_eq!(one.first_prompt, None);
244 assert_eq!(one.result, None);
245 assert_eq!(one.branch, None);
246 assert_eq!(one.model, None);
247 assert_eq!((one.cost_usd, one.cost_partial), (None, false));
248 assert_eq!(one.item, Some(4));
249 assert_eq!(one.actor.username, "a");
250 for kind in [SessionEntryKind::Prompt, SessionEntryKind::ToolCall, SessionEntryKind::Note, SessionEntryKind::Message] {
251 assert!(entry(an_entry(kind, "x"), Sight::Outside).is_none());
252 }
253 }
254
255 #[test]
256 fn a_member_sees_the_work_not_someone_elses_words() {
257 let mut one = a_session();
258 session(&mut one, Sight::Member);
259 assert_eq!(one.title.as_deref(), Some("Fix this login with G1T_TOKEN=[redacted]"));
260 assert_eq!(one.first_prompt, None);
261 assert_eq!(one.result.as_deref(), Some("done; token ghp_[redacted]"));
262 assert_eq!((one.cost_usd, one.cost_partial), (Some(4.12), true));
263 let prompt = entry(an_entry(SessionEntryKind::Prompt, "wtf, fix it"), Sight::Member).unwrap();
264 assert!(prompt.withheld && prompt.text.is_empty());
265 let output = entry(an_entry(SessionEntryKind::ToolResult, "AWS_SECRET=x"), Sight::Member).unwrap();
266 assert!(output.withheld);
267 let call = entry(an_entry(SessionEntryKind::ToolCall, "bash: curl -H 'Authorization: Bearer abcdefgh1234'"), Sight::Member).unwrap();
268 assert!(!call.withheld && !call.text.contains("abcdefgh1234"));
269 }
270
271 #[test]
272 fn an_outsiders_own_session_hears_of_members_sessions_as_an_outsider() {
273 let data = serde_json::json!({"session": {"title": "Rotate keys", "prompt": "secret plan", "id": "ses_2", "actor": {"username": "b"}}, "files": ["a.rs"]});
274 let one = SessionUpdate { id: "upd_1".into(), kind: "overlap.detected".into(), data, at: String::new() };
275 let shown = update(one.clone(), false);
276 assert_eq!(shown.data["session"]["title"], serde_json::Value::Null);
277 assert_eq!(shown.data["session"]["prompt"], serde_json::Value::Null);
278 assert_eq!(shown.data["session"]["id"], "ses_2");
279 assert_eq!(shown.data["files"][0], "a.rs");
280 assert_eq!(update(one, true).data["session"]["title"], "Rotate keys");
281 }
282
283 #[test]
284 fn its_author_sees_it_all_without_credentials() {
285 let mut one = a_session();
286 session(&mut one, Sight::Own);
287 assert_eq!(one.title.as_deref(), Some("Fix this fucking login with G1T_TOKEN=[redacted]"));
288 assert_eq!(one.first_prompt.as_deref(), Some("ugh fix it"));
289 let prompt = entry(an_entry(SessionEntryKind::Prompt, "use G1T_TOKEN=abc123def456"), Sight::Own).unwrap();
290 assert!(!prompt.withheld);
291 assert_eq!(prompt.text, "use G1T_TOKEN=[redacted]");
292 }
293}