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.

Commit

Sessions show by who is looking, and nothing that looks like a credential is kept: on a public repository anyone could read every prompt, command and subagent report a session recorded (coordination_repo only asked whether they could read the repository). Now whoever ran a session sees all of it; a member of the workspace sees what it did (title, tool calls, messages, notes, skills, files, subagents) but not the prompts someone else typed nor a tool's output, which show as kept back (SessionEntry.withheld), and a title without swearing (soften); anyone else sees that it runs, who runs it, its harness, state and times and the issues and pull requests it is tied to, nothing it recorded, touched, was asked or reported, nor its title, branch or model (SessionDetail.limited). The same holds for lists (Live now, Work), a pull request's session (outsiders see only g1t's notes; prompts and tool output only to their author, by the session that recorded them) and its sessions list's prompt (members only). Pending updates are shaped too: an outsider's own session on a public repository hears of members' sessions without their titles, prompts, tasks, results, branches or models.

Credentials: crates/scan/src/redact.rs replaces with [redacted] every format secret scanning knows, prefixed tokens (g1t_, ghp_, sk-ant-, whsec_, npm_ … with a letter and a digit), NAME_TOKEN=value and password: value (references like $TOKEN, ${{ secrets.X }}, process.env.X and types like token: String are left), --token/--password flags, curl -u, Authorization and Bearer headers, a URL's user:password and signed query parameters, JWTs, PEM private keys, and long random-looking strings. The work service applies it as it records (entries, titles, tasks, prompts, results, a pull request's record_session) and again as it reads, for what was recorded before. The Claude Code plugin (0.2.9) does the same before anything leaves the machine: prompts, titles, subagents' prompts, tasks and results, not only command lines as before; its old redact missed G1T_TOKEN=… (the digit) and $env: assignments. Cloud sessions: a Managed Agents session's page has Open in Claude Console for whoever started it (integrations agent_session_link, which tells anyone else, or another repository, there is no such session); the Console shows everything the session was asked and every tool's output, so nobody else gets the link. Its page-side half went out in 74236dee, which needs this commit to typecheck.

Docs:
the sessions guide's Who sees what, and the plugin, bring-your-own-agent and why-blame guides no longer say a session is as visible as its repository. Deploy: work, integrations, api, the site; the plugin release. Checked: cargo test -p g1t-scan (72; new: redact), -p g1t-work (214; new: shown), -p g1t-contracts (319), -p g1t-integrations agent_sessions (11); cargo check -p g1t-api; wasm32 check of work and integrations; node --test plugins/claude-code/test/hooks.test.mjs (59; new: credentials wherever text is sent); apps/web tsc -b; npm test (987, 1 skipped).
G1t-Session:
ses_01m501jq3vetja2s4a904rs068
syntaqxcommitted Parent8b53af5Browse files
26 files+816−500/26 viewed
+1−1
77 "name": "g1t",
88 "source": "./plugins/claude-code",
99 "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.",
10− "version": "0.2.8",
10+ "version": "0.2.9",
1111 "category": "development"
1212 }
1313 ]
+1−0
16461646 "g1t-contracts",
16471647 "g1t-kit",
16481648 "g1t-rules",
1649+ "g1t-scan",
16491650 "getrandom 0.2.17",
16501651 "hex",
16511652 "rusqlite",
+1−1
301301 }
302302
303303 fn entry(seq: u32, kind: SessionEntryKind, text: &str, commit: Option<&str>) -> SessionEntry {
304− SessionEntry { seq, kind, text: text.into(), tool: None, commit: commit.map(String::from), at: String::new() }
304+ SessionEntry { seq, kind, text: text.into(), tool: None, commit: commit.map(String::from), at: String::new(), withheld: false }
305305 }
306306
307307 #[test]
+4−1
180180 (`prompt`, `message`, `tool_call`, `tool_result` or `note`) and `text`,
181181 and tool entries also carry the `tool` name. Record before each push: an
182182 entry is tied to the commit that was the head when it was recorded. Do not
183−put secrets in a session; sessions are as visible as the repository.
183+put secrets in a session: g1t takes out what looks like a credential, and
184+shows what a session recorded only to the workspace's members (a prompt
185+only to whoever typed it), but that is a safety net. See
186+[Who sees what](/guides/sessions/#who-sees-what).
184187
185188 ### Recording sessions automatically
186189
+6−1
8787 ## What it records
8888
8989 The plugin's hooks call the `g1t` CLI, signed in as you, so a session is
90−recorded as yours and is as visible as the repository it is in.
90+recorded as yours. Before anything is sent, the plugin takes out what
91+looks like a credential (`[redacted]`) from command lines, prompts,
92+titles, and what subagents were asked and reported; g1t does it again as
93+it records. Your prompts show only to you; the workspace's members see the
94+rest, and anyone else who can read the repository sees only that the
95+session runs ([Who sees what](/guides/sessions/#who-sees-what)).
9196
9297 | When | The hook runs | What g1t receives |
9398 | --- | --- | --- |
+30−4
160160 on it with `record_session`, so reviewers and why-blame see them. Entries
161161 recorded before the session named its pull request move onto it when it
162162 does. A session never records tool output unless you send it as a
163−`tool_result` entry; a session is as visible as the repository, so keep
164−secrets out of it.
163+`tool_result` entry. What looks like a credential is taken out of
164+everything a session records, and only the workspace's members see inside
165+a session: see [Who sees what](#who-sees-what).
165166
166167 List a repository's sessions with `session` `list`, or
167168 [`GET /repos/{owner}/{name}/sessions`](/reference/api/sessions/list-sessions/)
346347 report. g1t follows it every minute, and at once while its page is open. A
347348 call that waits for someone's approval is declined, since nobody is
348349 watching. When it finishes, its session ends with its last message as its
349−result. Whoever started it can stop it from its page. It runs on your
350+result. Whoever started it can stop it from its page, and open it on the
351+Claude Console from there (**Open in Claude Console**); nobody else is
352+given that link, since the Console shows everything the session was asked
353+and every tool's output. It runs on your
350354 workspace's key and Anthropic bills the account for it; g1t bills nothing.
351355
352356 ## Assign g1t, or ask it in the thread
503507 the repository's sessions or claims are next read or changed, not at the
504508 minute it lapsed.
505509
510+## Who sees what
511+
512+A session records someone's own work as it happens, so g1t shows it by who
513+is looking, on the site, the API, MCP and the CLI alike.
514+
515+| Who | Sees |
516+| --- | --- |
517+| Whoever ran it | All of it: its prompts, tool calls, what it touched and reported, its pending updates. |
518+| A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. |
519+| Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the issues and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch or model. On a pull request's session, only g1t's own notes. |
520+
521+Before anything is kept, g1t takes out what looks like a credential
522+(`[redacted]`): every token format [secret protection](/guides/security/secret-protection/)
523+knows, any key or token by its issuer's prefix, `NAME_TOKEN=value`,
524+`password: value` and the like, `--token`/`--password` flags, `Bearer` and
525+`Authorization` headers, a URL's user and password and its signed query
526+parameters, JWTs, private keys, and long random-looking strings. What was
527+recorded before this is hidden the same way as it is read. The
528+[Claude Code plugin](/guides/claude-code-plugin/) does the same before
529+anything leaves your machine. It is a safety net, not a vault: keep
530+secrets out of prompts and command lines.
531+
506532 ## Who may do what
507533
508534 | To | You need |
509535 | --- | --- |
510−| Read sessions, claims and an issue's context | To be able to read the repository. Public repositories need no token. |
536+| Read sessions, claims and an issue's context | To be able to read the repository. Public repositories need no token. What a session shows depends on who you are: see [Who sees what](#who-sees-what). |
511537 | Start a session, claim an issue, open one by claiming it | A confirmed email address and a role that may open issues; the repository must not be archived. |
512538 | Update, record in or end a session | To be the person whose token started it. |
513539 | Release a claim or hand it off | To be the person who made it. |
+6−2
2727
2828 Read a session on the pull request's **Session** tab, with the
2929 `pull_request` tool's `read_session` action,
30−or with `GET /repos/{owner}/{name}/pulls/{number}/session?after=`. A
31−session is as visible as the repository, so do not put secrets in one.
30+or with `GET /repos/{owner}/{name}/pulls/{number}/session?after=`.
31+Outside the workspace only g1t's notes show there, and members see a
32+prompt or a tool's output only when it is their own; what looks like a
33+credential is taken out of all of it
34+([Who sees what](/guides/sessions/#who-sees-what)). Still, do not put
35+secrets in one.
3236
3337 ### Recording a session
3438
+22−4
3232 );
3333 }
3434
35+/** A prompt someone else typed: that there was one, not what it said. */
36+function KeptPrompt({ entry }: { entry: SessionEntry }) {
37+ return (
38+ <li className="flex items-center gap-2 px-4 py-3">
39+ <Terminal size={14} className="shrink-0 text-faint" />
40+ <span className="shrink-0 font-medium text-fg-soft">Prompt</span>
41+ <span className="min-w-0 grow truncate text-xs text-faint">Shows only to whoever typed it.</span>
42+ <At at={entry.at} />
43+ </li>
44+ );
45+}
46+
3547 /** The prompt, folded under its first line. */
3648 function Prompt({ entry, base }: { entry: SessionEntry; base?: string }) {
3749 const [open, setOpen] = useState(false);
119131 </CollapsibleTrigger>
120132 <CollapsibleContent>
121133 {long && call && <Block>{call.detail}</Block>}
122− {step.results.map((result) => (
123− <Block key={result.seq}>{result.text}</Block>
124− ))}
134+ {step.results.map((result) =>
135+ result.withheld ? (
136+ <p key={result.seq} className="text-xs text-faint">
137+ Its output shows only to whoever ran it.
138+ </p>
139+ ) : (
140+ <Block key={result.seq}>{result.text}</Block>
141+ ),
142+ )}
125143 </CollapsibleContent>
126144 </Collapsible>
127145 ) : (
176194 }
177195
178196 function Entry({ entry, base }: { entry: SessionEntry; base?: string }) {
179− if (entry.kind === "prompt") return <Prompt entry={entry} base={base} />;
197+ if (entry.kind === "prompt") return entry.withheld ? <KeptPrompt entry={entry} /> : <Prompt entry={entry} base={base} />;
180198 if (entry.kind === "message") {
181199 return (
182200 <li className="flex gap-2.5 px-4 py-3 text-sm">
+13−0
162162 pub id: String,
163163 }
164164
165+/// `agent_session_link`: where whoever started a Managed Agents session
166+/// opens it on the Anthropic account it runs on (the Claude Console).
167+/// Nobody else is given it: the session's events hold what it was asked
168+/// and every tool's output, which g1t shows no one but them. Returns
169+/// `Outcome<String>`.
170+#[derive(Debug, Serialize, Deserialize)]
171+pub struct AgentSessionLinkArgs {
172+ pub actor: User,
173+ pub repo: RepoPath,
174+ /// The g1t session (`ses_…`) at the top of its tree.
175+ pub id: String,
176+}
177+
165178 /// `stop_agent_session`: interrupts a Managed Agents session and ends its
166179 /// record. Only whoever started it. Returns `Outcome<bool>`.
167180 #[derive(Debug, Serialize, Deserialize)]
+4−0
10071007 pub commit: Option<String>,
10081008 /// RFC 3339.
10091009 pub at: String,
1010+ /// Its text is not shown to this viewer: a prompt is its author's own,
1011+ /// and a tool's output stays with whoever ran it. `text` is empty.
1012+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1013+ pub withheld: bool,
10101014 }
10111015
10121016 #[derive(Clone, Debug, Serialize, Deserialize)]
+5−0
766766 /// For a subagent: the session at the top of its tree.
767767 #[serde(default)]
768768 pub root: Option<Session>,
769+ /// The viewer is not a member of the repository's workspace: they see
770+ /// that the session runs, who runs it and what it is tied to, and
771+ /// nothing of what it recorded, touched or was asked.
772+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
773+ pub limited: bool,
769774 }
770775
771776 /// `pull_sessions`: the harness sessions tied to some of a repository's
+1−0
1313 pub mod osv;
1414 pub mod pack;
1515 pub mod protection;
16+pub mod redact;
1617 pub mod review;
1718 pub mod sarif;
1819 pub mod sbom;
+284−0
1+//! Hiding credentials in text people and agents write about their work: a
2+//! session's prompts, tool calls and messages, its title and what it
3+//! reported. Unlike [`crate::secrets`], which must be sure before it stops
4+//! a push, this errs the other way: a word too many hidden costs nothing,
5+//! a key shown on a public page costs the key.
6+//!
7+//! [`redact`] replaces what looks like a credential with [`REDACTED`]:
8+//! every format [`crate::secrets`] knows, prefixed tokens of any length,
9+//! `NAME_TOKEN=value` and `password: value`, credential flags, bearer
10+//! headers, a URL's user and password and its signed query parameters,
11+//! JWTs, PEM private keys, and long random-looking strings. [`soften`]
12+//! takes swearing out of a title, so that how someone spoke to their own
13+//! agent is not what their team reads.
14+
15+use std::sync::LazyLock;
16+
17+use regex::{Captures, Regex};
18+
19+/// What a credential is replaced with.
20+pub const REDACTED: &str = "[redacted]";
21+
22+fn re(pattern: &str) -> Regex {
23+ Regex::new(pattern).expect("a redaction pattern")
24+}
25+
26+/// A key or token by its issuer's prefix: `ghp_…`, `sk-ant-…`, `g1t_…`.
27+static PREFIXED: LazyLock<Regex> = LazyLock::new(|| {
28+ re(r"\b(g1t|ghp|gho|ghu|ghs|ghr|github_pat|glpat|gloas|glrt|glptt|gldt|xox[abeprs]|sk-ant|sk-proj|sk-svcacct|sk-admin|sk|rk|whsec|npm|hf|pypi|dop_v1|shpat|shpss|sntrys|lin_api|figd)([-_])([A-Za-z0-9_-]{12,})")
29+});
30+
31+/// `AKIA…`, `AIza…`, `SG.….…`: formats whose prefix needs no separator.
32+static BARE: LazyLock<Regex> =
33+ LazyLock::new(|| re(r"\b(?:(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})\b"));
34+
35+/// `Authorization: Bearer …`, `Proxy-Authorization: Basic …`.
36+static AUTH_HEADER: LazyLock<Regex> =
37+ LazyLock::new(|| re(r"(?i)\b((?:proxy-)?authorization\s*[:=]\s*(?:(?:bearer|basic|token|bot|digest)\s+)?)[^\s\x22'`,;]+"));
38+
39+/// `Bearer …` anywhere else.
40+static BEARER: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"));
41+
42+/// `G1T_TOKEN=…`, `$env:API_KEY='…'`, `"password": "…"`, `client_secret: …`.
43+static ASSIGNED: LazyLock<Regex> = LazyLock::new(|| {
44+ re(r#"(?i)([A-Za-z0-9_.-]*(?:token|secret|passw(?:or)?d|pwd|api[_-]?key|apikey|private[_-]?key|access[_-]?key|auth[_-]?key|signing[_-]?key|session[_-]?key|credentials?|webhook[_-]?url|dsn|connection[_-]?string)["']?\s*(?:=|:)\s*)("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|[^\s,;&)}\]]+)"#)
45+});
46+
47+/// `--token abc`, `--password=abc`, `--api-key abc`.
48+static FLAG: LazyLock<Regex> = LazyLock::new(|| {
49+ re(r"(?i)(--(?:token|password|passwd|secret|api-key|apikey|access-token|auth-token|client-secret|private-key)(?:=|\s+))(\x22[^\x22]*\x22|'[^']*'|\S+)")
50+});
51+
52+/// `curl -u name:password`.
53+static USER_FLAG: LazyLock<Regex> = LazyLock::new(|| re(r"((?:^|\s)(?:-u|--user)\s+[^\s:]+:)\S+"));
54+
55+/// `https://name:password@host`.
56+static USERINFO: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([a-z][a-z0-9+.-]*://)[^\s/:@]+:[^\s/@]+@"));
57+
58+/// `?token=…`, `&X-Amz-Signature=…`.
59+static QUERY: LazyLock<Regex> = LazyLock::new(|| {
60+ re(r"(?i)([?&](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|client_secret|sig|signature|password|code|x-amz-signature|x-amz-credential|x-amz-security-token)=)[^&\s#\x22'`]+")
61+});
62+
63+/// A JSON Web Token.
64+static JWT: LazyLock<Regex> = LazyLock::new(|| re(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"));
65+
66+/// A PEM private key, to its end or to the end of the text.
67+static PEM: LazyLock<Regex> =
68+ LazyLock::new(|| re(r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)"));
69+
70+/// A long run that may be random: judged by [`random_looking`].
71+static LONG: LazyLock<Regex> = LazyLock::new(|| re(r"[A-Za-z0-9+/_=-]{32,}"));
72+
73+/// A value that names a secret rather than holding one: `$TOKEN`,
74+/// `${{ secrets.X }}`, `process.env.X`, or one already hidden.
75+fn reference(value: &str) -> bool {
76+ let value = value.trim_matches(['"', '\'', '`']);
77+ value.is_empty()
78+ || value.starts_with('$')
79+ || value.starts_with('%')
80+ || value.starts_with('<')
81+ || value.contains("secrets.")
82+ || value.contains("env.")
83+ || value.contains("[redacted")
84+ || value == "…"
85+}
86+
87+fn shannon(text: &str) -> f64 {
88+ let mut counts = std::collections::HashMap::new();
89+ for c in text.chars() {
90+ *counts.entry(c).or_insert(0usize) += 1;
91+ }
92+ let length = text.chars().count() as f64;
93+ counts
94+ .values()
95+ .map(|count| {
96+ let p = *count as f64 / length;
97+ -p * p.log2()
98+ })
99+ .sum()
100+}
101+
102+/// Upper case, lower case and digits mixed with the spread of a random
103+/// string: a key with no prefix. A commit (lowercase hex), a path, a
104+/// CamelCase name or a word run together does not look like this.
105+fn random_looking(run: &str) -> bool {
106+ let digits = run.chars().filter(char::is_ascii_digit).count();
107+ run.chars().any(|c| c.is_ascii_uppercase())
108+ && run.chars().any(|c| c.is_ascii_lowercase())
109+ && digits >= 3
110+ && !run.contains("//")
111+ && shannon(run) >= 4.3
112+}
113+
114+fn has_letter_and_digit(body: &str) -> bool {
115+ body.chars().any(|c| c.is_ascii_alphabetic()) && body.chars().any(|c| c.is_ascii_digit())
116+}
117+
118+/// `text` with what looks like a credential replaced by [`REDACTED`].
119+pub fn redact(text: &str) -> String {
120+ if text.is_empty() {
121+ return String::new();
122+ }
123+ // Exact formats first, whatever surrounds them.
124+ let mut out = text.to_owned();
125+ let mut values: Vec<String> = crate::secrets::scan_text(text).into_iter().map(|hit| hit.value).collect();
126+ values.sort_by_key(|value| std::cmp::Reverse(value.len()));
127+ for value in values {
128+ // A private key's value is its header and the start of its body.
129+ let value = value.split('\n').next_back().unwrap_or(&value).to_owned();
130+ if value.len() >= 8 {
131+ out = out.replace(&value, REDACTED);
132+ }
133+ }
134+ let out = PEM.replace_all(&out, REDACTED);
135+ let out = JWT.replace_all(&out, REDACTED);
136+ let out = PREFIXED.replace_all(&out, |c: &Captures| {
137+ if has_letter_and_digit(&c[3]) { format!("{}{}{REDACTED}", &c[1], &c[2]) } else { c[0].to_owned() }
138+ });
139+ let out = BARE.replace_all(&out, REDACTED);
140+ let out = AUTH_HEADER.replace_all(&out, |c: &Captures| {
141+ let value = &c[0][c[1].len()..];
142+ if reference(value) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
143+ });
144+ let out = BEARER.replace_all(&out, |c: &Captures| {
145+ let value = &c[0][c[1].len()..];
146+ if reference(value) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
147+ });
148+ let out = USERINFO.replace_all(&out, format!("${{1}}{REDACTED}@").as_str());
149+ let out = QUERY.replace_all(&out, format!("${{1}}{REDACTED}").as_str());
150+ let out = FLAG.replace_all(&out, |c: &Captures| {
151+ if reference(&c[2]) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
152+ });
153+ let out = USER_FLAG.replace_all(&out, format!("${{1}}{REDACTED}").as_str());
154+ let out = ASSIGNED.replace_all(&out, |c: &Captures| {
155+ let value = &c[2];
156+ // `token: string` in code is a type, not a token.
157+ let typed = matches!(value, "string" | "String" | "str" | "&str" | "number" | "bool" | "boolean" | "null" | "None" | "undefined" | "true" | "false" | "Option<String>");
158+ if reference(value) || typed { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
159+ });
160+ let out = LONG.replace_all(&out, |c: &Captures| {
161+ if random_looking(&c[0]) { REDACTED.to_owned() } else { c[0].to_owned() }
162+ });
163+ out.into_owned()
164+}
165+
166+/// Words that say how someone felt, not what they asked for.
167+const SWEARING: &[&str] = &[
168+ "fuck", "fucking", "fucked", "fucker", "fuckin", "motherfucker", "motherfucking", "shit", "shitty", "bullshit", "horseshit",
169+ "damn", "damned", "dammit", "damnit", "goddamn", "goddamned", "goddammit", "wtf", "wth", "ffs", "stfu", "omfg", "asshole",
170+ "bitch", "bitching", "crap", "crappy", "idiot", "idiotic", "moron", "moronic", "dumbass", "retard", "retarded", "bastard",
171+ "pissed", "jfc", "fml",
172+];
173+
174+fn swearing(word: &str) -> bool {
175+ let bare: String = word.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase();
176+ !bare.is_empty() && (SWEARING.contains(&bare.as_str()) || bare.starts_with("fuck") || bare.starts_with("shit"))
177+}
178+
179+/// A title as the team reads it: credentials hidden and swearing left out.
180+/// Empty when nothing else was said.
181+pub fn soften(title: &str) -> String {
182+ let redacted = redact(title);
183+ let mut words: Vec<&str> = Vec::new();
184+ for word in redacted.split_whitespace() {
185+ if swearing(word) {
186+ // Its punctuation stays with the sentence: "this, fucking thing" reads "this, thing".
187+ continue;
188+ }
189+ words.push(word);
190+ }
191+ let text = words.join(" ");
192+ let text = text.trim_matches(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | ':' | '-'));
193+ let mut chars = text.chars();
194+ match chars.next() {
195+ // A title that lost its first word still starts as a sentence.
196+ Some(first) if words.len() < redacted.split_whitespace().count() => first.to_uppercase().chain(chars).collect(),
197+ Some(_) => text.to_owned(),
198+ None => String::new(),
199+ }
200+}
201+
202+#[cfg(test)]
203+mod tests {
204+ use super::*;
205+
206+ fn hidden(text: &str, secret: &str) {
207+ let out = redact(text);
208+ assert!(!out.contains(secret), "{secret} shows in {out}");
209+ assert!(out.contains(REDACTED), "nothing hidden in {out}");
210+ }
211+
212+ #[test]
213+ fn tokens_by_their_prefix() {
214+ hidden("source ~/.bashrc; curl -H 'x' g1t_0123456789abcdef0123456789abcdef01234567", "0123456789abcdef0123");
215+ hidden("use ghp_A1b2C3d4E5f6G7h8I9j0 please", "A1b2C3d4E5f6G7h8I9j0");
216+ hidden("key sk-ant-api03-abcDEF123456ghiJKL", "abcDEF123456ghiJKL");
217+ hidden("whsec_1234567890abcdefXYZ", "1234567890abcdefXYZ");
218+ hidden("AKIAIOSFODNN7EXAMPLE was it", "IOSFODNN7EXAMPLE");
219+ // Names that only share a prefix are left alone.
220+ assert_eq!(redact("npm_config_user_agent is set"), "npm_config_user_agent is set");
221+ assert_eq!(redact("sk-learn-classifier"), "sk-learn-classifier");
222+ }
223+
224+ #[test]
225+ fn assignments_flags_and_headers() {
226+ hidden("G1T_TOKEN=abc123def456 g1t whoami", "abc123def456");
227+ hidden("$env:G1T_TOKEN='abc123def456'; g1t whoami", "abc123def456");
228+ hidden(r#"{"password": "hunter2!"}"#, "hunter2");
229+ hidden("client_secret: s3cr3tvalue", "s3cr3tvalue");
230+ hidden("DATABASE_PASSWORD=\"correct horse\"", "correct horse");
231+ hidden("g1t auth login --token abc123", "abc123");
232+ hidden("x --password=pa55", "pa55");
233+ hidden("curl -u admin:pa55word https://x", "pa55word");
234+ hidden(r#"curl -H "Authorization: Bearer abc.def-123" https://api"#, "abc.def-123");
235+ hidden("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz");
236+ hidden("send it with bearer abcdefgh12345678", "abcdefgh12345678");
237+ hidden("X-Api-Key: 1234abcd5678", "1234abcd5678");
238+ }
239+
240+ #[test]
241+ fn references_and_types_are_not_secrets() {
242+ let line = "TOKEN=$G1T_TOKEN; token: ${{ secrets.DEPLOY }}; password: process.env.PW; fn f(token: String)";
243+ assert_eq!(redact(line), line);
244+ assert_eq!(redact(r#"-H "Authorization: Bearer …""#), r#"-H "Authorization: Bearer …""#);
245+ }
246+
247+ #[test]
248+ fn urls_jwts_and_keys() {
249+ hidden("git clone https://x-access-token:abc123@g1t.sh/a/b", "abc123");
250+ hidden("postgres://app:pa55@db:5432/app", "pa55");
251+ hidden("open https://x.com/cb?code=abc&state=1&token=t0k3n", "t0k3n");
252+ hidden("GET /file?X-Amz-Signature=deadbeef1234&x=1", "deadbeef1234");
253+ hidden("jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "dozjgNryP4J3");
254+ let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----\nafter";
255+ let out = redact(pem);
256+ assert!(!out.contains("b3BlbnNzaC1"), "{out}");
257+ assert!(out.ends_with("after"));
258+ hidden("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn", "MIIEpAIBAAKCAQEA");
259+ }
260+
261+ #[test]
262+ fn random_strings_go_and_ordinary_ones_stay() {
263+ hidden("the key is Xk9fQ2mZr7Lp4Wv8Ty3Nb6Hc1Jd5Ge0S", "Xk9fQ2mZr7Lp4Wv8Ty3Nb6Hc1Jd5Ge0S");
264+ for ordinary in [
265+ "git show 58def4040e93a1dcda5f388f55360f3cd46cd97e",
266+ "services/work/src/coordination/sessions.rs",
267+ "CoordinationOp::StartAgentSession and ListWorkSessionsArgs",
268+ "MSYS_NO_PATHCONV=1 git show origin/40-g1t_upkeep:.g1t/upkeep.yml",
269+ "Fix the merge queue: it re-tests every pull request after a push",
270+ ] {
271+ assert_eq!(redact(ordinary), ordinary);
272+ }
273+ }
274+
275+ #[test]
276+ fn swearing_is_left_out_of_titles() {
277+ assert_eq!(soften("Fix this fucking bug"), "Fix this bug");
278+ assert_eq!(soften("wtf, why is the PR page slow"), "Why is the PR page slow");
279+ assert_eq!(soften("Make the shitty button work"), "Make the button work");
280+ assert_eq!(soften("fuck"), "");
281+ assert_eq!(soften("Shell scripts and hello world"), "Shell scripts and hello world");
282+ assert_eq!(soften("Rotate G1T_TOKEN=abc123def456 now"), format!("Rotate G1T_TOKEN={REDACTED} now"));
283+ }
284+}
+4−1
22 * Starting work on an issue in a session on the person's own Claude.
33 * Mirrors `crates/contracts/src/agent_sessions.rs` (the integrations
44 * service's `start_agent_session`, `agent_sessions_setup`,
5− * `sync_agent_session` and `stop_agent_session`).
5+ * `sync_agent_session`, `agent_session_link` and `stop_agent_session`).
66 */
77 import type { ServiceBinding } from "./clients";
88 import type { User, Viewer } from "./identity";
5959 start(actor: User, repo: RepoPath, input: StartAgentSessionInput): Promise<Result<AgentSessionStarted>>;
6060 /** Reads the session now (it is otherwise followed every minute). Whether it is still going. */
6161 sync(repo: RepoPath, id: string, viewer: Viewer): Promise<Result<boolean>>;
62+ /** Where it opens on the Claude Console. Only whoever started it is told. */
63+ link(actor: User, repo: RepoPath, id: string): Promise<Result<string>>;
6264 /** Interrupts it and ends its record. Only whoever started it. */
6365 stop(actor: User, repo: RepoPath, id: string): Promise<Result<boolean>>;
6466 }
9193 handoff: input.handoff ?? null,
9294 }),
9395 sync: (repo, id, viewer) => call("sync_agent_session", { repo, id, viewer }),
96+ link: (actor, repo, id) => call("agent_session_link", { actor, repo, id }),
9497 stop: (actor, repo, id) => call("stop_agent_session", { actor, repo, id }),
9598 };
9699 }
+10−0
406406 commit: string | null;
407407 /** RFC 3339. */
408408 at: string;
409+ /**
410+ * Its text is not shown to this viewer (`text` is empty): a prompt shows
411+ * only to whoever typed it, and a tool's output to whoever ran it.
412+ */
413+ withheld?: boolean;
409414 };
410415
411416 export type NewSessionEntry = Pick<SessionEntry, "kind" | "text"> &
15511556 skills?: SessionSkill[];
15521557 /** For a subagent: the session at the top of its tree. */
15531558 root?: WorkSession | null;
1559+ /**
1560+ * The viewer is not a member of the repository's workspace: the session
1561+ * shows that it runs, who runs it and what it is tied to, nothing more.
1562+ */
1563+ limited?: boolean;
15541564 };
15551565
15561566 /** Reading coordination: work service methods (services/work/src/coordination, decisions.rs). */
+1−1
11 {
22 "name": "g1t",
3− "version": "0.2.8",
3+ "version": "0.2.9",
44 "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.",
55 "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" },
66 "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/",
+57−17
3636 // that is not a tool's output: an Agent call's answer, which is its
3737 // subagent's final message (as SubagentStop gives it) and its id. Prompts
3838 // are not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0.
39+// Whatever is sent has what looks like a credential taken out first
40+// (redact): command lines, prompts, titles, subagents' prompts and results.
41+// On g1t a prompt shows only to whoever typed it.
3942 //
4043 // A session that starts where there is no g1t remote is recorded from the
4144 // prompt, or the git or g1t command, after which there is one (lateStart).
108111 return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
109112 }
110113
111−/** Hides what looks like a secret in a command line before it is sent. */
114+/** What a credential is replaced with, as g1t itself does (crates/scan/src/redact.rs). */
115+export const REDACTED = "[redacted]";
116+
117+/** A value that names a secret rather than holding one: `$TOKEN`, `${{ secrets.X }}`, `process.env.X`. */
118+function reference(value) {
119+ const v = String(value).replace(/^["'`]|["'`]$/g, "");
120+ return !v || /^[$%<]/.test(v) || v.includes("secrets.") || v.includes("env.") || v.includes("[redacted") || v === "…";
121+}
122+
123+/** `token: string` in code is a type, not a token. */
124+const TYPES = new Set(["string", "String", "str", "&str", "number", "bool", "boolean", "null", "None", "undefined", "true", "false"]);
125+
126+const PREFIXED =
127+ /\b(g1t|ghp|gho|ghu|ghs|ghr|github_pat|glpat|gloas|glrt|glptt|gldt|xox[abeprs]|sk-ant|sk-proj|sk-svcacct|sk-admin|sk|rk|whsec|npm|hf|pypi|dop_v1|shpat|shpss|sntrys|lin_api|figd)([-_])([A-Za-z0-9_-]{12,})/g;
128+const ASSIGNED =
129+ /([A-Za-z0-9_.-]*(?:token|secret|passw(?:or)?d|pwd|api[_-]?key|apikey|private[_-]?key|access[_-]?key|auth[_-]?key|signing[_-]?key|session[_-]?key|credentials?|webhook[_-]?url|dsn|connection[_-]?string)["']?\s*(?:=|:)\s*)("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|[^\s,;&)}\]]+)/gi;
130+const QUERY =
131+ /([?&](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|client_secret|sig|signature|password|code|x-amz-signature|x-amz-credential|x-amz-security-token)=)[^&\s#"'`]+/gi;
132+const FLAG = /(--(?:token|password|passwd|secret|api-key|apikey|access-token|auth-token|client-secret|private-key)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
133+
134+/**
135+ * Hides what looks like a credential before it is sent: in a command
136+ * line, a prompt, a title, or what a subagent was asked and reported.
137+ * g1t hides them again as it records and shows them; this keeps them from
138+ * leaving the machine at all.
139+ */
112140 export function redact(text) {
141+ const keep = (whole, head, value) => (reference(value) ? whole : `${head}${REDACTED}`);
113142 return String(text)
114− .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…")
115− .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…")
116− .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…")
117− .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…")
118− .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@");
143+ .replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g, REDACTED)
144+ .replace(/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, REDACTED)
145+ .replace(PREFIXED, (whole, prefix, sep, body) => (/[A-Za-z]/.test(body) && /\d/.test(body) ? `${prefix}${sep}${REDACTED}` : whole))
146+ .replace(/\b(?:(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})\b/g, REDACTED)
147+ .replace(/\b((?:proxy-)?authorization\s*[:=]\s*(?:(?:bearer|basic|token|bot|digest)\s+)?)([^\s"'`,;]+)/gi, keep)
148+ .replace(/\b(bearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi, keep)
149+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, `$1${REDACTED}@`)
150+ .replace(QUERY, `$1${REDACTED}`)
151+ .replace(FLAG, keep)
152+ .replace(/((?:^|\s)(?:-u|--user)\s+[^\s:]+:)\S+/g, `$1${REDACTED}`)
153+ .replace(ASSIGNED, (whole, head, value) => (TYPES.has(value) ? whole : keep(whole, head, value)));
154+}
155+
156+/** Text a person or an agent wrote, as it is sent: credentials out, cut to `limit`. */
157+export function scrub(text, limit) {
158+ return clip(redact(text), limit);
119159 }
120160
121161 /** A path as the repository names it: relative to its root, with forward slashes; null outside it. */
319359 // A turn Claude Code starts itself for a background task carries that
320360 // task's output, which is never sent: it is not a prompt of yours.
321361 if (!prompt.trim() || /^\s*<task-notification>/.test(prompt)) return null;
322− if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(prompt, PROMPT_LIMIT)] });
362+ if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", scrub(prompt, PROMPT_LIMIT)] });
323363 // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent.
324364 const skill = typedSkill(prompt);
325365 if (skill) job.steps.push(skillStep(skill));
326366 // What it is for titles the session, until it has a title (see hook and work).
327− const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(prompt);
367+ const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(redact(prompt));
328368 if (intent) job.steps.push({ title: intent });
329369 return job.steps.length ? job : null;
330370 }
337377 const args = ["session", "end", "--reason", "done"];
338378 const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : "";
339379 // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value.
340− if (result) args.push(`--result=${clip(result, RESULT_LIMIT)}`);
380+ if (result) args.push(`--result=${scrub(result, RESULT_LIMIT)}`);
341381 job.steps.push({ args });
342382 return job;
343383 }
365405 job.subagent = {
366406 id: String(agentId),
367407 type: input.subagent_type || null,
368− task: input.description ? clip(input.description, TASK_LIMIT) : null,
369− prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : clip(input.prompt, AGENT_PROMPT_LIMIT),
408+ task: input.description ? scrub(input.description, TASK_LIMIT) : null,
409+ prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : scrub(input.prompt, AGENT_PROMPT_LIMIT),
370410 model: response.resolvedModel || null,
371− result: response.status === "completed" ? clip(agentResult(response), RESULT_LIMIT) || null : null,
411+ result: response.status === "completed" ? scrub(agentResult(response), RESULT_LIMIT) || null : null,
372412 };
373413 // Its task, kept for its subagent, is no other subagent's (see work).
374414 if (event.tool_use_id) job.toolUseId = String(event.tool_use_id);
606646 write(files.task(toolUseId || `${now}-${randomUUID()}`), JSON.stringify({
607647 tool_use_id: toolUseId || null,
608648 type: input?.subagent_type || null,
609− task: input?.description ? clip(input.description, TASK_LIMIT) : null,
610− prompt: input?.prompt ? clip(input.prompt, AGENT_PROMPT_LIMIT) : null,
649+ task: input?.description ? scrub(input.description, TASK_LIMIT) : null,
650+ prompt: input?.prompt ? scrub(input.prompt, AGENT_PROMPT_LIMIT) : null,
611651 key: input?.prompt ? promptKey(input.prompt) : null,
612652 at: now,
613653 }));
715755 evidence = subagentEvidence(paths);
716756 }
717757 const task = takeTask(files, { type: agent.type, toolUseId: evidence.toolUseId, prompt: evidence.prompt });
718− const what = task?.task ?? (evidence.description ? clip(evidence.description, TASK_LIMIT) : null);
719− const prompt = task?.prompt ?? (evidence.prompt ? clip(evidence.prompt, AGENT_PROMPT_LIMIT) : null);
758+ const what = task?.task ?? (evidence.description ? scrub(evidence.description, TASK_LIMIT) : null);
759+ const prompt = task?.prompt ?? (evidence.prompt ? scrub(evidence.prompt, AGENT_PROMPT_LIMIT) : null);
720760 if (what) agent.task = what;
721761 if (prompt && env.G1T_RECORD_PROMPTS !== "0") agent.prompt = prompt;
722762 return agent;
11211161 const kept = readJson(files.unrecorded, {}) ?? {};
11221162 const output = sessionStart(event, env, findCli(env));
11231163 if (!output) {
1124− const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(event.prompt) : null;
1164+ const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(redact(event.prompt)) : null;
11251165 if (intent) write(files.unrecorded, JSON.stringify({ ...kept, title: intent }));
11261166 return null;
11271167 }
+20−0
315315 assert.ok(!line.includes("--token abc"));
316316 });
317317
318+ test("hide credentials wherever text is sent, and leave references alone", () => {
319+ for (const [text, secret] of [
320+ ["$env:G1T_TOKEN='abc123def456'; g1t whoami", "abc123def456"],
321+ ["G1T_TOKEN=abc123def456 g1t whoami", "abc123def456"],
322+ ['{"password": "hunter2!"}', "hunter2"],
323+ ["curl -u admin:pa55word https://x", "pa55word"],
324+ ["open https://x.com/cb?state=1&token=t0k3n", "t0k3n"],
325+ ["jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N", "dozjgNryP4J3"],
326+ ["-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5v\n-----END OPENSSH PRIVATE KEY-----", "b3BlbnNzaC1"],
327+ ["here is my key: sk-ant-api03-abcDEF123456ghiJKL, use it", "abcDEF123456ghiJKL"],
328+ ]) {
329+ const out = redact(text);
330+ assert.ok(!out.includes(secret), `${secret} in ${out}`);
331+ assert.ok(out.includes("[redacted]"), out);
332+ }
333+ for (const kept of ["TOKEN=$G1T_TOKEN", "token: ${{ secrets.DEPLOY }}", "fn f(token: String)", "npm_config_user_agent", "git show 58def4040e93a1dcda5f388f55360f3cd46cd97e"]) {
334+ assert.equal(redact(kept), kept);
335+ }
336+ });
337+
318338 test("find the issue in a branch name", () => {
319339 assert.equal(issueFromBranch("42-fix-login"), 42);
320340 assert.equal(issueFromBranch("issue-7"), 7);
+18−1
8989 ];
9090
9191 /// The methods this module answers.
92−pub const METHODS: [&str; 5] = ["start_agent_session", "agent_sessions_setup", "sync_agent_session", "stop_agent_session", "sync_agent_sessions"];
92+pub const METHODS: [&str; 6] = ["start_agent_session", "agent_sessions_setup", "sync_agent_session", "agent_session_link", "stop_agent_session", "sync_agent_sessions"];
9393
9494 // --- Requests, built without calling anything ------------------------------
9595
14701470 Ok(Outcome::Ok(self.sync_run(run).await?))
14711471 }
14721472
1473+ /// `agent_session_link`: the session on the Claude Console, for
1474+ /// whoever started it and no one else. Anyone else, or another
1475+ /// repository, is told there is no such session, not that it is theirs.
1476+ pub(crate) async fn agent_session_link(&self, a: AgentSessionLinkArgs) -> Result<Outcome<String>> {
1477+ let missing = || Outcome::fail(FailureCode::NotFound, "No session g1t started has that id.");
1478+ let Some(run) = self.run_row(a.id.trim()).await? else {
1479+ return Ok(missing());
1480+ };
1481+ let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase();
1482+ if run.repo.to_lowercase() != repo || run.actor().id != a.actor.id || a.actor.id.is_empty() {
1483+ return Ok(missing());
1484+ }
1485+ let console_workspace = self.ai_row(&run.connection_id).await?.and_then(|row| row.config().console_workspace);
1486+ Ok(Outcome::Ok(console_url(console_workspace.as_deref(), &run.provider_session_id)))
1487+ }
1488+
14731489 /// `stop_agent_session`: interrupts the session and ends its record.
14741490 pub(crate) async fn stop_agent_session(&self, a: StopAgentSessionArgs) -> Result<Outcome<bool>> {
14751491 let Some(run) = self.run_row(a.id.trim()).await? else {
15441560 "start_agent_session" => reply(&service.start_agent_session(args(body)?).await?),
15451561 "agent_sessions_setup" => reply(&service.agent_sessions_setup(args(body)?).await?),
15461562 "sync_agent_session" => reply(&service.sync_agent_session(args(body)?).await?),
1563+ "agent_session_link" => reply(&service.agent_session_link(args(body)?).await?),
15471564 "stop_agent_session" => reply(&service.stop_agent_session(args(body)?).await?),
15481565 "sync_agent_sessions" => reply(&service.sync_running().await?),
15491566 _ => worker::Response::error("Unknown method", 404),
+1−0
1212 g1t-contracts.workspace = true
1313 g1t-kit.workspace = true
1414 g1t-rules.workspace = true
15+g1t-scan.workspace = true
1516 serde.workspace = true
1617 serde_json.workspace = true
1718 worker.workspace = true
+1−0
1616 mod links;
1717 mod overlaps;
1818 mod sessions;
19+pub(crate) mod shown;
1920 #[cfg(test)]
2021 mod tests;
2122
+14−10
3030 /// The most pull requests one `pull_sessions` asks about.
3131 const MAX_PULL_SESSIONS: usize = 100;
3232 use super::updates::push_delivery;
33−use super::{SESSION_COLUMNS, SessionRow, after_minutes, tidy, unwrap_or_fail};
33+use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, unwrap_or_fail};
3434 use crate::Work;
3535 use crate::rows::NumberRow;
3636
268268 pull,
269269 branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS),
270270 head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS),
271− title: tidy(a.title.as_deref(), MAX_TITLE_CHARS),
271+ title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS),
272272 agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS),
273− task: clip(a.task.as_deref(), MAX_TASK_CHARS),
274− prompt: clip(a.prompt.as_deref(), MAX_PROMPT_CHARS),
273+ task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS),
274+ prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS),
275275 model: tidy(a.model.as_deref(), MAX_MODEL_CHARS),
276276 result: None,
277277 };
534534 pull,
535535 branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS),
536536 head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS),
537− title: tidy(a.title.as_deref(), MAX_TITLE_CHARS),
537+ title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS),
538538 agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS),
539− task: clip(a.task.as_deref(), MAX_TASK_CHARS),
540− prompt: clip(a.prompt.as_deref(), MAX_PROMPT_CHARS),
539+ task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS),
540+ prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS),
541541 model: tidy(a.model.as_deref(), MAX_MODEL_CHARS),
542− result: clip(a.result.as_deref(), MAX_RESULT_CHARS),
542+ result: clip(a.result.as_deref().map(shown::clean).as_deref(), MAX_RESULT_CHARS),
543543 };
544544 self.resume(&repo, row, &a.actor, fields).await
545545 }
706706 ids.extend(children.iter().map(|child| child.id.clone()));
707707 let (touched, links, skills) = self.tree_facts(&repo.id, &ids).await?;
708708 let updates = self.pending_updates(&session.id).await?;
709− Ok(Outcome::Ok(SessionDetail { session, entries, children, touched, links, skills, root, updates }))
709+ let detail = SessionDetail { session, entries, children, touched, links, skills, root, updates, limited: false };
710+ Ok(Outcome::Ok(shown::detail(detail, &a.viewer, &repo.namespace)))
710711 }
711712
712713 /// The sessions tied to some pull requests, for Home (`PullSessionsArgs`).
812813 self.fill_subagents(&mut sessions).await?;
813814 }
814815 self.fill_first_prompts(&mut sessions).await?;
816+ shown::sessions(&mut sessions, &a.viewer, &repo.namespace);
815817 Ok(Outcome::Ok(sessions))
816818 }
817819
896898 for keyed in a.entries {
897899 let entry = keyed.entry;
898900 let kind = serde_json::to_value(entry.kind)?;
899− let mut text: String = entry.text.chars().take(crate::MAX_ENTRY_CHARS).collect();
901+ // Kept without credentials: a session's page may show it to a
902+ // whole team, and its prompts to its author on any device.
903+ let mut text: String = shown::clean(&entry.text.chars().take(crate::MAX_ENTRY_CHARS).collect::<String>());
900904 if entry.kind == SessionEntryKind::Skill {
901905 // A skill is its name, nothing more.
902906 text = text.trim().chars().take(MAX_AGENT_CHARS).collect();
+282−0
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+
15+use g1t_contracts::Viewer;
16+use g1t_contracts::work::{Session, SessionDetail, SessionEntry, SessionEntryKind, SessionUpdate};
17+use g1t_scan::redact::{redact, soften};
18+
19+/// How much of a session a viewer sees.
20+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21+pub(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+
30+impl 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.
41+pub(crate) fn clean(text: &str) -> String {
42+ redact(text)
43+}
44+
45+/// [`clean`], for a field that may be absent.
46+pub(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.
51+pub(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.
85+pub(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.
101+fn 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.
111+const 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.
115+fn 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.
133+fn 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`.
142+pub(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.
166+pub(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)]
174+mod 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+}
+1−1
27712771 let mut statements = Vec::with_capacity(a.entries.len() + 1);
27722772 for entry in a.entries {
27732773 let kind = serde_json::to_value(entry.kind)?;
2774− let text: String = entry.text.chars().take(MAX_ENTRY_CHARS).collect();
2774+ let text = coordination::shown::clean(&entry.text.chars().take(MAX_ENTRY_CHARS).collect::<String>());
27752775 // Each insert takes the next sequence number itself, so two
27762776 // writers appending at once cannot collide.
27772777 statements.push(
+4−0
396396 pub tool: Option<String>,
397397 pub commit: Option<String>,
398398 pub at: String,
399+ /// Whose harness session recorded it, where the query says.
400+ #[serde(default)]
401+ pub actor_id: Option<String>,
399402 }
400403
401404 impl From<SessionRow> for SessionEntry {
407410 tool: row.tool,
408411 commit: row.commit,
409412 at: row.at,
413+ withheld: false,
410414 }
411415 }
412416 }
+25−5
1313 use g1t_contracts::agents::*;
1414 use g1t_contracts::repos::{Repo, RepoPath};
1515 use g1t_contracts::time::rfc3339;
16−use g1t_contracts::work::{PullStatus, SessionEntry};
16+use g1t_contracts::work::{PullStatus, SessionEntry, SessionEntryKind};
17+use g1t_scan::redact::soften;
1718 use g1t_contracts::{FailureCode, Membership, Outcome, User, Viewer, new_id};
1819 use g1t_kit::now_ms;
1920 use serde::Deserialize;
2122 use worker::wasm_bindgen::JsValue;
2223
2324 use crate::checks::{hash, new_token};
25+use crate::coordination::shown::{self, Sight};
2426 use crate::rows::SessionRow;
2527 use crate::{Work, optional};
2628
531533 agent: row.agent,
532534 entries: row.entries,
533535 tools: row.tools,
534− prompt: row.prompt.map(|prompt| one_line(&prompt, 200)),
536+ // Someone's words to their agent: not for outsiders,
537+ // and to members without credentials or swearing.
538+ prompt: row.prompt.filter(|_| member).map(|prompt| soften(&one_line(&prompt, 200))).filter(|prompt| !prompt.is_empty()),
535539 runs: mine.len() as u32,
536540 cost_usd: (member && mine.iter().any(|run| run.cost_usd.is_some())).then_some(spent),
537541 active: mine
552556 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
553557 };
554558 let member = is_member(&a.viewer, &repo.namespace);
559+ // Each entry as the viewer may see it: a prompt or a tool's output
560+ // only by whoever's session recorded it, nothing to an outsider
561+ // but g1t's own notes (coordination/shown.rs).
555562 let entries: Vec<SessionEntry> = self
556563 .db
557564 .prepare(
558− "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries
559− WHERE pull_id = ? ORDER BY seq LIMIT ?",
565+ "SELECT e.seq, e.kind, e.text, e.tool, e.\"commit\", e.at, s.actor_id FROM session_entries e
566+ LEFT JOIN sessions s ON s.id = e.session_id
567+ WHERE e.pull_id = ? ORDER BY e.seq LIMIT ?",
560568 )
561569 .bind(&[pull.id.as_str().into(), SESSION_ENTRIES.into()])?
562570 .all()
563571 .await?
564572 .results::<SessionRow>()?
565573 .into_iter()
566− .map(SessionEntry::from)
574+ .filter_map(|row| {
575+ let note = row.kind == SessionEntryKind::Note && row.actor_id.is_none();
576+ let sight = match &row.actor_id {
577+ Some(actor) => Sight::of(&a.viewer, &repo.namespace, actor),
578+ None if member => Sight::Member,
579+ None => Sight::Outside,
580+ };
581+ let entry = SessionEntry::from(row);
582+ if note && sight == Sight::Outside {
583+ return Some(SessionEntry { text: shown::clean(&entry.text), ..entry });
584+ }
585+ shown::entry(entry, sight)
586+ })
567587 .collect();
568588 let runs: Vec<AgentRun> = self
569589 .db

Comments

Sign in to comment.