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.

282 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.end_reason = session.end_reason.take().filter(|reason| reason == "timed_out");
80 }
81 }
82}
83
84/// An entry as `sight` sees it; `None` when it is not shown at all.
85pub(crate) fn entry(mut entry: SessionEntry, sight: Sight) -> Option<SessionEntry> {
86 match sight {
87 Sight::Outside => None,
88 Sight::Member if matches!(entry.kind, SessionEntryKind::Prompt | SessionEntryKind::ToolResult) => {
89 entry.text = String::new();
90 entry.withheld = true;
91 Some(entry)
92 }
93 _ => {
94 entry.text = redact(&entry.text);
95 Some(entry)
96 }
97 }
98}
99
100/// Every string in an update's data, credentials out.
101fn clean_value(value: &mut serde_json::Value) {
102 match value {
103 serde_json::Value::String(text) => *text = redact(text),
104 serde_json::Value::Array(items) => items.iter_mut().for_each(clean_value),
105 serde_json::Value::Object(fields) => fields.values_mut().for_each(clean_value),
106 _ => {}
107 }
108}
109
110/// What an update may say of another session that only members see.
111const MEMBERS_ONLY: &[&str] = &["title", "first_prompt", "prompt", "task", "result", "branch", "head_commit", "harness_session_id", "model", "agent_type", "end_reason"];
112
113/// An update's data without what only members see: what other sessions
114/// are titled, were asked, reported and run on.
115fn outside_value(value: &mut serde_json::Value) {
116 match value {
117 serde_json::Value::Array(items) => items.iter_mut().for_each(outside_value),
118 serde_json::Value::Object(fields) => {
119 for key in MEMBERS_ONLY {
120 if fields.get(*key).is_some_and(|value| !value.is_null()) {
121 fields.insert((*key).to_owned(), serde_json::Value::Null);
122 }
123 }
124 fields.values_mut().for_each(outside_value);
125 }
126 _ => {}
127 }
128}
129
130/// An update as its session's agent reads it: credentials out, and for
131/// someone outside the workspace (on a public repository, their own
132/// session hears of members' sessions) only what an outsider may see.
133fn update(mut update: SessionUpdate, member: bool) -> SessionUpdate {
134 clean_value(&mut update.data);
135 if !member {
136 outside_value(&mut update.data);
137 }
138 update
139}
140
141/// A session's page as `viewer` sees it, on a repository of `namespace`.
142pub(crate) fn detail(mut detail: SessionDetail, viewer: &Viewer, namespace: &str) -> SessionDetail {
143 let sight = Sight::of(viewer, namespace, &detail.session.actor.id);
144 session(&mut detail.session, sight);
145 detail.entries = detail.entries.into_iter().filter_map(|one| entry(one, sight)).collect();
146 for child in &mut detail.children {
147 session(child, Sight::of(viewer, namespace, &child.actor.id));
148 }
149 if let Some(root) = &mut detail.root {
150 session(root, Sight::of(viewer, namespace, &root.actor.id));
151 }
152 if sight == Sight::Outside {
153 detail.touched.clear();
154 detail.skills.clear();
155 detail.updates.clear();
156 detail.children.clear();
157 detail.limited = true;
158 } else {
159 let member = viewer.as_ref().is_some_and(|viewer| viewer.is_member(&namespace.to_lowercase()));
160 detail.updates = detail.updates.into_iter().map(|one| update(one, member)).collect();
161 }
162 detail
163}
164
165/// Sessions in a list, each as `viewer` sees it.
166pub(crate) fn sessions(sessions: &mut [Session], viewer: &Viewer, namespace: &str) {
167 for one in sessions {
168 let sight = Sight::of(viewer, namespace, &one.actor.id);
169 session(one, sight);
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use g1t_contracts::{Membership, User};
177 use g1t_contracts::credentials::Principal;
178 use g1t_contracts::work::{Harness, SessionState};
179
180 fn person(id: &str, workspaces: &[&str]) -> Viewer {
181 Some(User {
182 id: id.into(),
183 username: id.into(),
184 workspaces: workspaces.iter().map(|slug| Membership::member(slug.to_string())).collect(),
185 ..User::default()
186 })
187 }
188
189 fn a_session() -> Session {
190 Session {
191 id: "ses_1".into(),
192 repo_id: "rep_1".into(),
193 issue: Some(4),
194 pull: None,
195 branch: Some("secret-plans".into()),
196 actor: Principal { id: "usr_a".into(), username: "a".into() },
197 harness: Harness::ClaudeCode,
198 harness_session_id: Some("d1485".into()),
199 title: Some("Fix this fucking login with G1T_TOKEN=abc123def456".into()),
200 state: SessionState::Active,
201 head_commit: Some("58def40".into()),
202 started_at: String::new(),
203 last_seen_at: String::new(),
204 ended_at: None,
205 end_reason: None,
206 parent_id: None,
207 agent_type: None,
208 task: None,
209 prompt: None,
210 result: Some("done; token ghp_A1b2C3d4E5f6G7h8I9j0".into()),
211 model: Some("claude-opus-5-5".into()),
212 subagents: 0,
213 first_prompt: Some("ugh fix it".into()),
214 }
215 }
216
217 fn an_entry(kind: SessionEntryKind, text: &str) -> SessionEntry {
218 SessionEntry { seq: 1, kind, text: text.into(), tool: None, commit: None, at: String::new(), withheld: false }
219 }
220
221 #[test]
222 fn who_sees_what() {
223 assert_eq!(Sight::of(&person("usr_a", &[]), "acme", "usr_a"), Sight::Own);
224 assert_eq!(Sight::of(&person("usr_b", &["acme"]), "Acme", "usr_a"), Sight::Member);
225 assert_eq!(Sight::of(&person("usr_c", &["other"]), "acme", "usr_a"), Sight::Outside);
226 assert_eq!(Sight::of(&None, "acme", "usr_a"), Sight::Outside);
227 }
228
229 #[test]
230 fn an_outsider_sees_that_it_runs_and_nothing_inside() {
231 let mut one = a_session();
232 session(&mut one, Sight::Outside);
233 assert_eq!(one.title, None);
234 assert_eq!(one.first_prompt, None);
235 assert_eq!(one.result, None);
236 assert_eq!(one.branch, None);
237 assert_eq!(one.model, None);
238 assert_eq!(one.issue, Some(4));
239 assert_eq!(one.actor.username, "a");
240 for kind in [SessionEntryKind::Prompt, SessionEntryKind::ToolCall, SessionEntryKind::Note, SessionEntryKind::Message] {
241 assert!(entry(an_entry(kind, "x"), Sight::Outside).is_none());
242 }
243 }
244
245 #[test]
246 fn a_member_sees_the_work_not_someone_elses_words() {
247 let mut one = a_session();
248 session(&mut one, Sight::Member);
249 assert_eq!(one.title.as_deref(), Some("Fix this login with G1T_TOKEN=[redacted]"));
250 assert_eq!(one.first_prompt, None);
251 assert_eq!(one.result.as_deref(), Some("done; token ghp_[redacted]"));
252 let prompt = entry(an_entry(SessionEntryKind::Prompt, "wtf, fix it"), Sight::Member).unwrap();
253 assert!(prompt.withheld && prompt.text.is_empty());
254 let output = entry(an_entry(SessionEntryKind::ToolResult, "AWS_SECRET=x"), Sight::Member).unwrap();
255 assert!(output.withheld);
256 let call = entry(an_entry(SessionEntryKind::ToolCall, "bash: curl -H 'Authorization: Bearer abcdefgh1234'"), Sight::Member).unwrap();
257 assert!(!call.withheld && !call.text.contains("abcdefgh1234"));
258 }
259
260 #[test]
261 fn an_outsiders_own_session_hears_of_members_sessions_as_an_outsider() {
262 let data = serde_json::json!({"session": {"title": "Rotate keys", "prompt": "secret plan", "id": "ses_2", "actor": {"username": "b"}}, "files": ["a.rs"]});
263 let one = SessionUpdate { id: "upd_1".into(), kind: "overlap.detected".into(), data, at: String::new() };
264 let shown = update(one.clone(), false);
265 assert_eq!(shown.data["session"]["title"], serde_json::Value::Null);
266 assert_eq!(shown.data["session"]["prompt"], serde_json::Value::Null);
267 assert_eq!(shown.data["session"]["id"], "ses_2");
268 assert_eq!(shown.data["files"][0], "a.rs");
269 assert_eq!(update(one, true).data["session"]["title"], "Rotate keys");
270 }
271
272 #[test]
273 fn its_author_sees_it_all_without_credentials() {
274 let mut one = a_session();
275 session(&mut one, Sight::Own);
276 assert_eq!(one.title.as_deref(), Some("Fix this fucking login with G1T_TOKEN=[redacted]"));
277 assert_eq!(one.first_prompt.as_deref(), Some("ugh fix it"));
278 let prompt = entry(an_entry(SessionEntryKind::Prompt, "use G1T_TOKEN=abc123def456"), Sight::Own).unwrap();
279 assert!(!prompt.withheld);
280 assert_eq!(prompt.text, "use G1T_TOKEN=[redacted]");
281 }
282}