Skip to content

Commit cff0dfa

Browse files
authored
feat(ai): wire contentTrust into sync ingestion (#13691) (#13693)
1 parent 77bfadc commit cff0dfa

9 files changed

Lines changed: 579 additions & 266 deletions

File tree

ai/mcp/server/github-workflow/config.template.mjs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import path from 'path';
2-
import {fileURLToPath} from 'url';
1+
import path from 'path';
2+
import {fileURLToPath} from 'url';
33
import ConfigProvider, { createConfigProxy, leaf } from '../../../ConfigProvider.mjs';
44

55
const __filename = fileURLToPath(import.meta.url);
@@ -154,6 +154,13 @@ class Config extends ConfigProvider {
154154
* @type {{numbers: Number[], authors: String[]}}
155155
*/
156156
discussionDenylist: leaf({numbers: [], authors: []}),
157+
/**
158+
* Product names to redact from untrusted GitHub-authored content when the content-trust
159+
* sanitizer projects sync/write-boundary Markdown. Empty by default; policy values belong
160+
* in local config, not in syncer code.
161+
* @type {string[]}
162+
*/
163+
productNameDenylist: leaf([]),
157164
/**
158165
* The date from which to start synchronizing issues and releases.
159166
* @type {string}

ai/services/github-workflow/shared/conversationTrust.mjs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ import {sanitizeContent} from '../../shared/contentTrust/astroturfSanitizer.
2121
* @see ../../shared/contentTrust/astroturfSanitizer.mjs (the pure trust-gated transform)
2222
*/
2323

24+
/**
25+
* @summary Creates the machine-readable summary accumulated by content trust projection.
26+
* @returns {{projected: Boolean, quarantined: Number, signals: Object[]}}
27+
*/
28+
export function createContentTrustSummary() {
29+
return {projected: true, quarantined: 0, signals: []}
30+
}
31+
2432
/**
2533
* Projects one authored node (`{author, body, ...}`): stamps `authorTrust`, sanitizes an untrusted
2634
* author's `body`, and records redaction counts plus stealth-intent signals against `path`.
@@ -55,6 +63,28 @@ function projectNode(node, ctx, path) {
5563
return projected
5664
}
5765

66+
/**
67+
* @summary Projects one authored GitHub node through the shared content-trust policy.
68+
* @param {Object} node The authored node carrying optional `author.login` and `body`.
69+
* @param {Object} [opts]
70+
* @param {Set<String>|String[]} [opts.collaborators] repo collaborator logins for REPO_TRUSTED.
71+
* @param {String[]} [opts.productNameDenylist] product names redacted from untrusted content.
72+
* @param {Object} [opts.summary] Existing summary accumulator; created when omitted.
73+
* @param {String} [opts.path='body'] Signal location label.
74+
* @returns {{node: Object, contentTrust: Object}} Projected node plus the summary accumulator.
75+
*/
76+
export function projectAuthoredNodeTrust(node, {
77+
collaborators,
78+
productNameDenylist = [],
79+
summary = createContentTrustSummary(),
80+
path = 'body'
81+
} = {}) {
82+
return {
83+
node : projectNode(node, {collaborators, productNameDenylist, summary}, path),
84+
contentTrust: summary
85+
}
86+
}
87+
5888
/**
5989
* @summary Projects a fetched conversation payload into its trust-aware shape.
6090
*
@@ -77,7 +107,7 @@ export function projectConversationTrust(conversation, {collaborators, productNa
77107
return conversation
78108
}
79109

80-
const summary = {projected: true, quarantined: 0, signals: []};
110+
const summary = createContentTrustSummary();
81111
const ctx = {collaborators, productNameDenylist, summary};
82112

83113
const projected = projectNode(conversation, ctx, 'body');

ai/services/github-workflow/sync/DiscussionSyncer.mjs

Lines changed: 70 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import aiConfig from '../../../mcp/server/github-workflow/config.mjs';
2-
import Base from '../../../../src/core/Base.mjs';
3-
import crypto from 'crypto';
4-
import fs from 'fs/promises';
5-
import logger from '../../../mcp/server/github-workflow/logger.mjs';
6-
import matter from 'gray-matter';
7-
import path from 'path';
8-
import GraphqlService from '../GraphqlService.mjs';
9-
import ReleaseNotesSyncer from './ReleaseNotesSyncer.mjs';
1+
import aiConfig from '../../../mcp/server/github-workflow/config.mjs';
2+
import Base from '../../../../src/core/Base.mjs';
3+
import crypto from 'crypto';
4+
import fs from 'fs/promises';
5+
import logger from '../../../mcp/server/github-workflow/logger.mjs';
6+
import matter from 'gray-matter';
7+
import path from 'path';
8+
import GraphqlService from '../GraphqlService.mjs';
9+
import ReleaseNotesSyncer from './ReleaseNotesSyncer.mjs';
1010
import {FETCH_DISCUSSIONS_FOR_SYNC} from '../queries/discussionQueries.mjs';
1111
import contentPath from '../shared/contentPath.mjs';
1212
import {
1313
createContentIndexEntry,
1414
updateContentIndex
1515
} from '../shared/contentIndex.mjs';
16-
import pruneEmptyDirs from '../shared/pruneEmptyDirs.mjs';
17-
import {verifyDiscussionFrontmatter} from './verifyFrontmatterIntegrity.mjs';
16+
import {createContentTrustSummary, projectAuthoredNodeTrust} from '../shared/conversationTrust.mjs';
17+
import pruneEmptyDirs from '../shared/pruneEmptyDirs.mjs';
18+
import {verifyDiscussionFrontmatter} from './verifyFrontmatterIntegrity.mjs';
1819

1920
const issueSyncConfig = aiConfig.issueSync;
2021

@@ -77,6 +78,22 @@ class DiscussionSyncer extends Base {
7778
return path.relative(aiConfig.projectRoot, p);
7879
}
7980

81+
/**
82+
* @summary Projects one GitHub-authored discussion sync node through content trust policy.
83+
* @param {Object} node GitHub authored node carrying optional `author.login` and `body`.
84+
* @param {Object} summary Machine-readable content-trust summary accumulator.
85+
* @param {String} signalPath Stable path label for sanitizer signal metadata.
86+
* @returns {Object} Projected node with sanitized body for untrusted authors.
87+
* @private
88+
*/
89+
#projectAuthoredNode(node, summary, signalPath) {
90+
return projectAuthoredNodeTrust(node, {
91+
summary,
92+
path : signalPath,
93+
productNameDenylist: issueSyncConfig.productNameDenylist || []
94+
}).node;
95+
}
96+
8097
/**
8198
* @summary Pre-computes bucket counts and indices for all discussions based on historical releases.
8299
* @param {Object} metadata The sync metadata.
@@ -89,16 +106,16 @@ class DiscussionSyncer extends Base {
89106

90107
for (const [idStr, discussion] of Object.entries(metadata.discussions || {})) {
91108
combined.set(parseInt(idStr, 10), {
92-
number: parseInt(idStr, 10),
93-
closed: discussion.closed,
109+
number : parseInt(idStr, 10),
110+
closed : discussion.closed,
94111
closedAt: discussion.closedAt
95112
});
96113
}
97114

98115
for (const discussion of fetchedDiscussions) {
99116
combined.set(discussion.number, {
100-
number: discussion.number,
101-
closed: discussion.closed,
117+
number : discussion.number,
118+
closed : discussion.closed,
102119
closedAt: discussion.closedAt
103120
});
104121
}
@@ -230,9 +247,9 @@ class DiscussionSyncer extends Base {
230247

231248
while (hasNextPage) {
232249
const data = await GraphqlService.query(FETCH_DISCUSSIONS_FOR_SYNC, {
233-
owner: aiConfig.owner,
234-
repo : aiConfig.repo,
235-
limit: 50,
250+
owner : aiConfig.owner,
251+
repo : aiConfig.repo,
252+
limit : 50,
236253
cursor,
237254
maxComments: 50,
238255
maxReplies : 20
@@ -296,38 +313,53 @@ class DiscussionSyncer extends Base {
296313
try {
297314
const targetPath = this.#getDiscussionPath(discussion, planBuckets);
298315
if (!targetPath) continue;
316+
const contentTrust = createContentTrustSummary();
317+
const projectedDiscussion = this.#projectAuthoredNode(discussion, contentTrust, 'body');
299318

300319
const frontmatter = {
301-
number : discussion.number,
302-
title : discussion.title,
303-
author : discussion.author?.login || 'unknown',
304-
category : discussion.category?.name || 'Uncategorized',
305-
createdAt : discussion.createdAt,
306-
updatedAt : discussion.updatedAt,
307-
closed : discussion.closed,
308-
closedAt : discussion.closedAt
320+
number : discussion.number,
321+
title : discussion.title,
322+
author : discussion.author?.login || 'unknown',
323+
category : discussion.category?.name || 'Uncategorized',
324+
createdAt: discussion.createdAt,
325+
updatedAt: discussion.updatedAt,
326+
closed : discussion.closed,
327+
closedAt : discussion.closedAt,
328+
contentTrust
309329
};
310330

311-
let body = discussion.body || '';
331+
let body = projectedDiscussion.body || '';
312332

313333
// Build comments structure
314334
if (discussion.comments && discussion.comments.nodes && discussion.comments.nodes.length > 0) {
315335
body += '\n\n## Comments\n\n';
316336
for (const comment of discussion.comments.nodes) {
337+
const projectedComment = this.#projectAuthoredNode(
338+
comment,
339+
contentTrust,
340+
`comment:${comment.id || comment.createdAt || 'unknown'}`
341+
);
342+
317343
body += `### \`@${comment.author?.login || 'unknown'}\` commented on ${comment.createdAt}\n\n`;
318344
if (comment.isAnswer) {
319345
body += '> [!ANSWER]\n\n';
320346
}
321-
body += `${comment.body}\n\n`;
347+
body += `${projectedComment.body}\n\n`;
322348

323349
// Parse replies if any
324350
if (comment.replies && comment.replies.nodes && comment.replies.nodes.length > 0) {
325351
for (const reply of comment.replies.nodes) {
352+
const projectedReply = this.#projectAuthoredNode(
353+
reply,
354+
contentTrust,
355+
`comment:${comment.id || comment.createdAt || 'unknown'}/reply:${reply.id || reply.createdAt || 'unknown'}`
356+
);
357+
326358
body += `#### Reply depth=1 by \`@${reply.author?.login || 'unknown'}\` on ${reply.createdAt}\n\n`;
327359
if (reply.isAnswer) {
328360
body += '> [!ANSWER]\n\n';
329361
}
330-
body += `${reply.body}\n\n`;
362+
body += `${projectedReply.body}\n\n`;
331363
}
332364
}
333365
body += '---\n\n';
@@ -401,23 +433,23 @@ class DiscussionSyncer extends Base {
401433

402434
allDiscussions.forEach(d => {
403435
metadata.discussions[d.number] = {
404-
number: d.number,
405-
closed: d.closed,
406-
closedAt: d.closedAt,
436+
number : d.number,
437+
closed : d.closed,
438+
closedAt : d.closedAt,
407439
contentHash: d.contentHash,
408-
path: d.relativeOutputPath
440+
path : d.relativeOutputPath
409441
};
410442

411443
const plan = planBuckets.get(d.number);
412444

413445
indexEntries.push(createContentIndexEntry({
414446
issueSyncConfig,
415-
type: 'discussions',
416-
id: d.number,
417-
filePath: path.resolve(aiConfig.projectRoot, d.relativeOutputPath),
447+
type : 'discussions',
448+
id : d.number,
449+
filePath : path.resolve(aiConfig.projectRoot, d.relativeOutputPath),
418450
itemIndex: plan ? plan.itemIndex : 0,
419-
version: plan?.version || null,
420-
bucket: null
451+
version : plan?.version || null,
452+
bucket : null
421453
}));
422454
});
423455

0 commit comments

Comments
 (0)