@@ -118,6 +118,23 @@ const DOMAIN_PRODUCT: Record<string, PostHogProductId | null> = {
118118
119119const KNOWN_DOMAINS = Object . keys ( DOMAIN_PRODUCT ) ;
120120
121+ const escapeRe = ( s : string ) : string =>
122+ s . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
123+
124+ /**
125+ * One regex per domain: the domain must appear as a complete hyphen-delimited
126+ * token run anywhere in the sub-tool name, tolerating a plural trailing "s".
127+ * A single pattern covers `feature-flag-get` (prefix), `create-feature-flag`
128+ * (verb-first) and `feature-flags-status-retrieve` (plural) — so a new
129+ * `feature-flags-*` tool needs no entry here. The `(^|-)…($|-)` boundaries keep
130+ * a domain from matching a partial token, and `s?` matches zero for
131+ * already-plural domains like `external-data-sources`.
132+ */
133+ const DOMAIN_PATTERNS : ReadonlyArray < readonly [ string , RegExp ] > =
134+ KNOWN_DOMAINS . map (
135+ ( d ) => [ d , new RegExp ( `(^|-)${ escapeRe ( d ) } s?($|-)` ) ] as const ,
136+ ) ;
137+
121138/**
122139 * HogQL/PostHog table name → product. Lets an `execute-sql` call be attributed
123140 * to the product whose data it reads (e.g. `SELECT count() FROM feature_flags`
@@ -198,14 +215,97 @@ export function classifyPostHogSqlQuery(sql: string): PostHogProductId[] {
198215 return [ ...products ] ;
199216}
200217
218+ /**
219+ * Activity-log `scope` value → product. The activity-log tools are generic
220+ * audit readers (their own domain is suppressed), but a call scoped to a
221+ * specific entity type is really about that entity's product. Keys are the
222+ * PascalCase scope enum values; only scopes that map to a surfaced product are
223+ * listed — admin/meta scopes (Team, Project, User, Role, Comment, Tag, …) are
224+ * omitted so a scoped audit read of them surfaces nothing, as before.
225+ */
226+ const ACTIVITY_SCOPE_PRODUCT : Record < string , PostHogProductId > = {
227+ FeatureFlag : "feature_flags" ,
228+ EarlyAccessFeature : "feature_flags" ,
229+ Experiment : "experiments" ,
230+ ExperimentHoldout : "experiments" ,
231+ ExperimentSavedMetric : "experiments" ,
232+ ErrorTrackingIssue : "error_tracking" ,
233+ Replay : "session_replay" ,
234+ SessionRecordingPlaylist : "session_replay" ,
235+ Survey : "surveys" ,
236+ LLMTrace : "llm_analytics" ,
237+ Evaluation : "llm_analytics" ,
238+ DataWarehouseSavedQuery : "data_warehouse" ,
239+ ExternalDataSource : "data_warehouse" ,
240+ ExternalDataSchema : "data_warehouse" ,
241+ BatchExport : "data_warehouse" ,
242+ BatchImport : "data_warehouse" ,
243+ HogFunction : "cdp" ,
244+ HogFlow : "cdp" ,
245+ Log : "logs" ,
246+ LogsAlertConfiguration : "logs" ,
247+ LogsExclusionRule : "logs" ,
248+ WebAnalyticsFilterPreset : "web_analytics" ,
249+ Insight : "product_analytics" ,
250+ Dashboard : "product_analytics" ,
251+ DashboardWidget : "product_analytics" ,
252+ Cohort : "product_analytics" ,
253+ Person : "product_analytics" ,
254+ Group : "product_analytics" ,
255+ Notebook : "product_analytics" ,
256+ Action : "product_analytics" ,
257+ EventDefinition : "product_analytics" ,
258+ PropertyDefinition : "product_analytics" ,
259+ Annotation : "product_analytics" ,
260+ Endpoint : "product_analytics" ,
261+ EndpointVersion : "product_analytics" ,
262+ Subscription : "product_analytics" ,
263+ AlertConfiguration : "product_analytics" ,
264+ Threshold : "product_analytics" ,
265+ AlertSubscription : "product_analytics" ,
266+ } ;
267+
268+ /**
269+ * Attribute an activity-log call to the product(s) of its `scope`/`scopes`
270+ * argument. Returns an empty array when the body has no recognized scope (so an
271+ * unscoped or admin-scoped audit read still surfaces nothing).
272+ */
273+ function classifyPostHogActivityLog ( commandText : string ) : PostHogProductId [ ] {
274+ const start = commandText . indexOf ( "{" ) ;
275+ const end = commandText . lastIndexOf ( "}" ) ;
276+ if ( start === - 1 || end <= start ) return [ ] ;
277+ let body : unknown ;
278+ try {
279+ body = JSON . parse ( commandText . slice ( start , end + 1 ) ) ;
280+ } catch {
281+ return [ ] ;
282+ }
283+ if ( ! body || typeof body !== "object" ) return [ ] ;
284+ const scopesField = ( body as { scopes ?: unknown } ) . scopes ;
285+ const raw : unknown [ ] = [
286+ ( body as { scope ?: unknown } ) . scope ,
287+ ...( Array . isArray ( scopesField ) ? scopesField : [ ] ) ,
288+ ] ;
289+ const products = new Set < PostHogProductId > ( ) ;
290+ for ( const s of raw ) {
291+ if ( typeof s === "string" ) {
292+ const product = ACTIVITY_SCOPE_PRODUCT [ s ] ;
293+ if ( product ) products . add ( product ) ;
294+ }
295+ }
296+ return [ ...products ] ;
297+ }
298+
201299/**
202300 * Classify an executed MCP exec `call` into the products it touched. For
203301 * `execute-sql` the query text is inspected so the call is attributed to the
204302 * product whose tables it reads (e.g. Feature flags), falling back to the
205- * generic "sql" product only when no table maps. All other sub-tools resolve
206- * to their single domain product (or none, for admin/meta domains).
303+ * generic "sql" product only when no table maps. Activity-log reads are
304+ * attributed by their `scope` argument. All other sub-tools resolve to their
305+ * single domain product (or none, for admin/meta domains).
207306 *
208- * `commandText` is the raw exec command (which embeds the SQL for execute-sql).
307+ * `commandText` is the raw exec command (which embeds the SQL for execute-sql
308+ * and the scope JSON for activity-log reads).
209309 */
210310export function classifyPostHogExecCall (
211311 subTool : string ,
@@ -216,6 +316,13 @@ export function classifyPostHogExecCall(
216316 const fromTables = commandText ? classifyPostHogSqlQuery ( commandText ) : [ ] ;
217317 return fromTables . length > 0 ? fromTables : [ "sql" ] ;
218318 }
319+ if (
320+ name === "activity-log" ||
321+ name . startsWith ( "activity-log-" ) ||
322+ name . startsWith ( "advanced-activity-logs" )
323+ ) {
324+ return commandText ? classifyPostHogActivityLog ( commandText ) : [ ] ;
325+ }
219326 const product = classifyPostHogSubTool ( subTool ) ;
220327 return product ? [ product ] : [ ] ;
221328}
@@ -249,8 +356,8 @@ export function classifyPostHogSubTool(
249356 // Longest matching domain wins so `feature-flag` beats a hypothetical
250357 // `feature` and multi-word domains aren't shadowed by shorter prefixes.
251358 let best : string | null = null ;
252- for ( const domain of KNOWN_DOMAINS ) {
253- if ( name === domain || name . startsWith ( ` ${ domain } -` ) ) {
359+ for ( const [ domain , re ] of DOMAIN_PATTERNS ) {
360+ if ( re . test ( name ) ) {
254361 if ( best === null || domain . length > best . length ) best = domain ;
255362 }
256363 }
0 commit comments