Skip to content
175 linesCodeBlameRaw
1/**
2 * Who will read an agent's answer, and so what it may read to write it
3 * (docs/WORKSPACE.md, "What an agent can and can't know"). Built once per
4 * reply; every tool asks it before reading anything.
5 *
6 * The rules, decided here in code, never by the model:
7 * - The audience is the conversation's people: a DM's or a private
8 * channel's members. A public channel, or more than 50 people, is the
9 * whole workspace (`shared`).
10 * - Code, issues and pull requests come only from repositories of this
11 * workspace that every person in the audience can read: the repos
12 * service is asked once per person (`readable`) and the answers are
13 * intersected. Anyone in the audience without Code access, or anyone
14 * who could not be resolved, means no code at all.
15 * - A shared audience reads no code. Every member would have to be able to
16 * read it, and g1t cannot yet tell whether every member of a workspace
17 * has Code access; when in doubt, it denies.
18 * - The model's arguments never widen this: a repository is looked up only
19 * in the allow-list, by this workspace's name for it.
20 *
21 * The person who asked is one of the audience, so anything every member
22 * may read, they may too: tools call the backing services as them, after
23 * this check.
24 */
25import type { User } from "@g1t/contracts";
26
27export type AudienceInfo = { kind: "dm" | "private" | "public"; member_user_ids: string[]; member_count: number };
28
29/** A repository as the allow-list keeps it. */
30export type RepoRef = { id: string; namespace: string; name: string; isPrivate: boolean; defaultBranch: string; forkOf?: string | null };
31
32/** What building an audience reaches outside this module. */
33export interface AudiencePorts {
34 /** Who reads the conversation (the chat service works it out from the channel). */
35 info(): Promise<AudienceInfo>;
36 /** People by id, with their workspaces and grants; ids it cannot resolve are left out. */
37 users(ids: string[]): Promise<User[]>;
38 /** The workspace's repositories `viewer` can see. */
39 workspaceRepos(viewer: User): Promise<RepoRef[]>;
40 /** Of these ids, the repositories `viewer` can read. */
41 readable(ids: string[], viewer: User): Promise<RepoRef[]>;
42}
43
44/** Past this many people, an audience is the whole workspace's. */
45export const SHARED_OVER = 50;
46
47/** What a tool says when the audience may not see something: never whether it exists. */
48export const WITHHELD = "Not available in this conversation.";
49
50export class Audience {
51 readonly workspace: string;
52 readonly kind: AudienceInfo["kind"];
53 readonly shared: boolean;
54 /** The people, resolved; empty for a shared audience. */
55 readonly members: User[];
56 /** The person who asked, resolved, or null. */
57 readonly asker: User | null;
58 /** Whether every person could be resolved. */
59 readonly complete: boolean;
60 /** Stable for the same people: recorded with every tool call. */
61 readonly hash: string;
62 private readonly ports: AudiencePorts;
63 private allowed: Promise<Map<string, RepoRef>> | null = null;
64
65 private constructor(fields: {
66 workspace: string;
67 kind: AudienceInfo["kind"];
68 shared: boolean;
69 members: User[];
70 asker: User | null;
71 complete: boolean;
72 hash: string;
73 ports: AudiencePorts;
74 }) {
75 this.workspace = fields.workspace;
76 this.kind = fields.kind;
77 this.shared = fields.shared;
78 this.members = fields.members;
79 this.asker = fields.asker;
80 this.complete = fields.complete;
81 this.hash = fields.hash;
82 this.ports = fields.ports;
83 }
84
85 static async build(workspace: string, askerId: string, ports: AudiencePorts): Promise<Audience> {
86 const slug = workspace.toLowerCase();
87 const info = await ports.info();
88 const ids = [...new Set(info.member_user_ids)];
89 const shared = info.kind === "public" || ids.length > SHARED_OVER || info.member_count > SHARED_OVER;
90 const wanted = shared ? [askerId] : ids;
91 const people = wanted.length ? await ports.users(wanted) : [];
92 const asker = people.find((user) => user.id === askerId) ?? null;
93 const members = shared ? [] : people;
94 // Everyone found, the asker among them: anything less, and code is off.
95 const complete = !shared && ids.length > 0 && ids.every((id) => people.some((user) => user.id === id)) && ids.includes(askerId);
96 return new Audience({
97 workspace: slug,
98 kind: info.kind,
99 shared,
100 members,
101 asker,
102 complete,
103 hash: audienceHash(info.kind, shared ? [] : ids),
104 ports,
105 });
106 }
107
108 /** Whether code, issues and pull requests may be read at all for this audience. */
109 codeAllowed(): boolean {
110 if (this.shared || !this.complete || !this.asker) return false;
111 return this.members.every((user) => {
112 const membership = user.workspaces?.find((m) => m.slug.toLowerCase() === this.workspace);
113 // An outside collaborator reads through grants; a Chat-only member reads no code at all.
114 return membership ? membership.code_access !== false : (user.grants ?? []).some((grant) => grant.workspace.toLowerCase() === this.workspace);
115 });
116 }
117
118 /** The repositories every person in the audience can read, by lowercase `namespace/name`. */
119 repos(): Promise<Map<string, RepoRef>> {
120 this.allowed ??= this.computeRepos();
121 return this.allowed;
122 }
123
124 private async computeRepos(): Promise<Map<string, RepoRef>> {
125 const out = new Map<string, RepoRef>();
126 if (!this.codeAllowed() || !this.asker) return out;
127 const candidates = (await this.ports.workspaceRepos(this.asker)).filter(
128 (repo) => repo.namespace.toLowerCase() === this.workspace && !repo.forkOf,
129 );
130 let ids = new Set(candidates.map((repo) => repo.id));
131 for (const member of this.members) {
132 if (!ids.size) break;
133 const readable = new Set((await this.ports.readable([...ids], member)).map((repo) => repo.id));
134 ids = new Set([...ids].filter((id) => readable.has(id)));
135 }
136 for (const repo of candidates) {
137 if (ids.has(repo.id) && agentScopesAllow(repo)) out.set(`${repo.namespace}/${repo.name}`.toLowerCase(), repo);
138 }
139 return out;
140 }
141
142 /**
143 * A repository the model named (`name` or `namespace/name`), only if it
144 * is on the allow-list. Another workspace's, a private one someone can't
145 * read, or one that does not exist: all null, alike.
146 */
147 async repo(named: unknown): Promise<RepoRef | null> {
148 if (typeof named !== "string") return null;
149 const trimmed = named.trim().replace(/^\/+|\/+$/g, "").toLowerCase();
150 if (!trimmed || trimmed.split("/").length > 2) return null;
151 const full = trimmed.includes("/") ? trimmed : `${this.workspace}/${trimmed}`;
152 return (await this.repos()).get(full) ?? null;
153 }
154}
155
156/**
157 * Whether the agent's own scopes allow reading `repo`. Agents have no
158 * scopes field yet: within its workspace, an agent may read what its
159 * audience may. When scopes land, they narrow here.
160 */
161export function agentScopesAllow(_repo: RepoRef): boolean {
162 return true;
163}
164
165/** A short, stable fingerprint of who reads: kind and sorted people. */
166export function audienceHash(kind: string, ids: string[]): string {
167 const text = `${kind}:${[...ids].sort().join(",")}`;
168 let h1 = 0x811c9dc5;
169 let h2 = 0x01000193;
170 for (let i = 0; i < text.length; i++) {
171 h1 = Math.imul(h1 ^ text.charCodeAt(i), 16777619) >>> 0;
172 h2 = Math.imul(h2 + text.charCodeAt(i), 2654435761) >>> 0;
173 }
174 return `${h1.toString(16).padStart(8, "0")}${h2.toString(16).padStart(8, "0")}`;
175}