Skip to content

Commit e6c5244

Browse files
committed
address second round feedbac
1 parent e23bff7 commit e6c5244

8 files changed

Lines changed: 112 additions & 19 deletions

File tree

packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'
1+
import {mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'
22
import {tmpdir} from 'node:os'
33
import {join} from 'node:path'
44

@@ -7,7 +7,7 @@ import {resolveBrandProject} from './resolve-brand-project.js'
77
describe('resolveBrandProject', () => {
88
const directories: string[] = []
99
const temporaryWorkspace = () => {
10-
const directory = mkdtempSync(join(tmpdir(), 'primer-brand-project-'))
10+
const directory = realpathSync(mkdtempSync(join(tmpdir(), 'primer-brand-project-')))
1111
directories.push(directory)
1212
return directory
1313
}
@@ -64,6 +64,19 @@ describe('resolveBrandProject', () => {
6464
expect(resolveBrandProject(workspace, join(workspace, 'apps', 'site')).reason).toBe('invalid-brand-project')
6565
})
6666

67+
it('rejects a requested package symlink that resolves outside the workspace', () => {
68+
const workspace = temporaryWorkspace()
69+
const externalPackage = temporaryWorkspace()
70+
writePackage(externalPackage, {'@primer/react-brand': '^0.73.0'})
71+
mkdirSync(join(workspace, 'apps'), {recursive: true})
72+
symlinkSync(externalPackage, join(workspace, 'apps', 'site'), 'dir')
73+
74+
expect(resolveBrandProject(workspace, 'apps/site')).toMatchObject({
75+
projectDir: null,
76+
reason: 'invalid-brand-project',
77+
})
78+
})
79+
6780
it('reports when no declaring package or workspace root is available', () => {
6881
const workspace = temporaryWorkspace()
6982
writePackage(workspace, {react: '^19.0.0'})

packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {readdirSync, readFileSync} from 'node:fs'
2-
import {isAbsolute, join, relative, resolve} from 'node:path'
1+
import {readdirSync, readFileSync, realpathSync} from 'node:fs'
2+
import {isAbsolute, join, relative, resolve, sep} from 'node:path'
33

44
export type BrandProjectResolution = {
55
projectDir: string | null
@@ -37,15 +37,30 @@ export function resolveBrandProject(workspaceDir: string | null, requestedProjec
3737

3838
if (!workspaceDir) return {projectDir: null, candidates: [], reason: 'no-workspace-root'}
3939

40+
let canonicalWorkspaceDir: string
41+
try {
42+
canonicalWorkspaceDir = realpathSync(workspaceDir)
43+
} catch {
44+
return {projectDir: null, candidates: [], reason: 'no-workspace-root'}
45+
}
46+
47+
const isWithinWorkspace = (candidate: string): boolean => {
48+
const relativeCandidate = relative(canonicalWorkspaceDir, candidate)
49+
return relativeCandidate !== '..' && !relativeCandidate.startsWith(`..${sep}`) && !isAbsolute(relativeCandidate)
50+
}
51+
4052
if (requestedProjectDir) {
4153
if (isAbsolute(requestedProjectDir)) {
4254
return {projectDir: null, candidates: [], reason: 'invalid-brand-project'}
4355
}
44-
const candidate = resolve(workspaceDir, requestedProjectDir)
45-
const relativeCandidate = relative(workspaceDir, candidate)
46-
const isOutsideWorkspace = relativeCandidate.startsWith('..') || isAbsolute(relativeCandidate)
47-
return !isOutsideWorkspace && declaresBrand(candidate)
48-
? {projectDir: candidate, candidates: [candidate]}
56+
let canonicalCandidate: string
57+
try {
58+
canonicalCandidate = realpathSync(resolve(canonicalWorkspaceDir, requestedProjectDir))
59+
} catch {
60+
return {projectDir: null, candidates: [], reason: 'invalid-brand-project'}
61+
}
62+
return isWithinWorkspace(canonicalCandidate) && declaresBrand(canonicalCandidate)
63+
? {projectDir: canonicalCandidate, candidates: [canonicalCandidate]}
4964
: {projectDir: null, candidates: [], reason: 'invalid-brand-project'}
5065
}
5166

@@ -64,7 +79,7 @@ export function resolveBrandProject(workspaceDir: string | null, requestedProjec
6479
visit(join(directory, entry.name))
6580
}
6681
}
67-
visit(workspaceDir)
82+
visit(canonicalWorkspaceDir)
6883
candidates.sort()
6984

7085
if (candidates.length === 1) return {projectDir: candidates[0] ?? null, candidates}

packages/mcp/src/brand/update-agents-md/update-agents-md.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
1+
import {existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'
22
import {tmpdir} from 'node:os'
33
import {join} from 'node:path'
44

@@ -77,6 +77,27 @@ describe('updateAgentsMd', () => {
7777
expect(updateAgentsMd(wrongCaseDir)).toMatchObject({action: 'skipped', reason: 'incorrect-filename-case'})
7878
})
7979

80+
it('does not read or write AGENTS.md through symbolic links', () => {
81+
const projectDir = temporaryProject()
82+
const externalDir = temporaryProject()
83+
const externalFile = join(externalDir, 'instructions.md')
84+
writeFileSync(externalFile, '# External instructions\n')
85+
symlinkSync(externalFile, join(projectDir, 'AGENTS.md'))
86+
87+
expect(updateAgentsMd(projectDir)).toMatchObject({action: 'skipped', reason: 'unsafe-agents-path'})
88+
expect(readFileSync(externalFile, 'utf8')).toBe('# External instructions\n')
89+
})
90+
91+
it('does not create an external file through a dangling AGENTS.md symlink', () => {
92+
const projectDir = temporaryProject()
93+
const externalDir = temporaryProject()
94+
const externalFile = join(externalDir, 'future-instructions.md')
95+
symlinkSync(externalFile, join(projectDir, 'AGENTS.md'))
96+
97+
expect(updateAgentsMd(projectDir)).toMatchObject({action: 'skipped', reason: 'unsafe-agents-path'})
98+
expect(existsSync(externalFile)).toBe(false)
99+
})
100+
80101
it('skips when no project root is available', () => {
81102
expect(updateAgentsMd(null)).toEqual({action: 'skipped', path: null, reason: 'no-project-root'})
82103
})

packages/mcp/src/brand/update-agents-md/update-agents-md.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import {existsSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'
2-
import {join} from 'node:path'
1+
import {lstatSync, readFileSync, readdirSync, realpathSync, writeFileSync} from 'node:fs'
2+
import {isAbsolute, join, relative, sep} from 'node:path'
33

44
export type AgentsMdUpdateResult = {
55
action: 'created' | 'updated' | 'unchanged' | 'skipped'
66
path: string | null
7-
reason?: 'no-project-root' | 'incorrect-filename-case' | 'malformed-managed-block'
7+
reason?: 'no-project-root' | 'incorrect-filename-case' | 'malformed-managed-block' | 'unsafe-agents-path'
88
}
99

1010
/** Create or update the managed Primer Brand instructions in a project's root AGENTS.md. */
@@ -24,17 +24,42 @@ ${endMarker}`
2424

2525
if (!projectDir) return {action: 'skipped', path: null, reason: 'no-project-root'}
2626

27-
const agentMdCustomSetupPath = join(projectDir, filename)
28-
const caseInsensitiveMatch = readdirSync(projectDir).find(entry => entry.toLowerCase() === filename.toLowerCase())
27+
let canonicalProjectDir: string
28+
try {
29+
canonicalProjectDir = realpathSync(projectDir)
30+
} catch {
31+
return {action: 'skipped', path: null, reason: 'no-project-root'}
32+
}
33+
34+
const agentMdCustomSetupPath = join(canonicalProjectDir, filename)
35+
const caseInsensitiveMatch = readdirSync(canonicalProjectDir).find(
36+
entry => entry.toLowerCase() === filename.toLowerCase(),
37+
)
2938
if (caseInsensitiveMatch && caseInsensitiveMatch !== filename) {
3039
return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'incorrect-filename-case'}
3140
}
3241

33-
if (!existsSync(agentMdCustomSetupPath)) {
42+
const fileStats = lstatSync(agentMdCustomSetupPath, {throwIfNoEntry: false})
43+
if (fileStats?.isSymbolicLink()) {
44+
return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'}
45+
}
46+
47+
if (!fileStats) {
3448
writeFileSync(agentMdCustomSetupPath, `${block}\n`, 'utf8')
3549
return {action: 'created', path: agentMdCustomSetupPath}
3650
}
3751

52+
let canonicalAgentsPath: string
53+
try {
54+
canonicalAgentsPath = realpathSync(agentMdCustomSetupPath)
55+
} catch {
56+
return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'}
57+
}
58+
const relativeAgentsPath = relative(canonicalProjectDir, canonicalAgentsPath)
59+
if (relativeAgentsPath === '..' || relativeAgentsPath.startsWith(`..${sep}`) || isAbsolute(relativeAgentsPath)) {
60+
return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'}
61+
}
62+
3863
const existing = readFileSync(agentMdCustomSetupPath, 'utf8')
3964
const eol = existing.includes('\r\n') ? '\r\n' : '\n'
4065
const normalizedBlock = block.replaceAll('\n', eol)

packages/mcp/src/review/rules.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,12 @@ describe('primer_brand_review rules', () => {
232232
expect(ruleIds(review('<Heading size="4">Heading</Heading>'))).not.toContain('component-text-default-size')
233233
})
234234

235+
it('does not apply component-owned text guidance to application components', () => {
236+
expect(ruleIds(review('<PricingCard.Heading size="small">Pro</PricingCard.Heading>'))).not.toContain(
237+
'component-text-default-size',
238+
)
239+
})
240+
235241
it('reports which approved brand components were imported', () => {
236242
const used = brandComponentsUsed("import {Hero, CTABanner} from '@primer/react-brand'", makeCatalog())
237243
expect(used.map(component => component.name).sort()).toEqual(['CTABanner', 'Hero'])

packages/mcp/src/review/rules.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -441,15 +441,18 @@ const headingExplicitSize: Rule = {
441441
/** Compound components provide context-appropriate text sizes; explicit overrides should be exceptional. */
442442
const componentTextDefaultSize: Rule = {
443443
id: 'component-text-default-size',
444-
run(code) {
444+
run(code, catalog) {
445+
const knownSubcomponents = new Set(catalog.components.flatMap(component => component.subcomponents))
445446
const findings: Finding[] = []
446447
for (const match of code.matchAll(
447448
/<([A-Z][A-Za-z0-9]*)\.(Heading|Subheading|Description|Label|Eyebrow|Text)\b[^>]*(?:^|\s)size\s*=/g,
448449
)) {
450+
const qualifiedName = `${match[1]}.${match[2]}`
451+
if (!knownSubcomponents.has(qualifiedName)) continue
449452
findings.push({
450453
severity: 'warning',
451454
rule: this.id,
452-
message: `\`${match[1]}.${match[2]}\` already has the right default \`size\` for its component. Remove \`size\` unless the brief requires an override.`,
455+
message: `\`${qualifiedName}\` already has the right default \`size\` for its component. Remove \`size\` unless the brief requires an override.`,
453456
evidence: evidence(match[0]),
454457
})
455458
}

packages/mcp/src/test-utils/catalog.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ export function makeCatalog(overrides: Partial<Catalog> = {}): Catalog {
6161
},
6262
],
6363
},
64+
{
65+
name: 'Card',
66+
module: '@primer/react-brand',
67+
subcomponents: ['Card.Heading', 'Card.Description'],
68+
props: [],
69+
examples: [],
70+
},
6471
{name: 'Pillar', module: '@primer/react-brand', subcomponents: [], props: [], examples: []},
6572
{name: 'SectionIntro', module: '@primer/react-brand', subcomponents: [], props: [], examples: []},
6673
{name: 'River', module: '@primer/react-brand', subcomponents: [], props: [], examples: []},

packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ function build(id: FrameworkId, ctx: ToolContext, agentsMd: SetupAgentsMdResult)
168168
if (agentsMd.reason === 'malformed-managed-block') {
169169
return 'Skipped `AGENTS.md`: the Primer Brand managed markers are malformed or duplicated; repair or remove that block, then rerun setup.'
170170
}
171+
if (agentsMd.reason === 'unsafe-agents-path') {
172+
return 'Skipped `AGENTS.md`: the existing path is a symbolic link or resolves outside the selected package. Replace it with a regular file inside the package, then rerun setup.'
173+
}
171174
return 'Skipped `AGENTS.md`: no project root was detected.'
172175
}
173176
})()

0 commit comments

Comments
 (0)