fix(agents): reject file bases and preserve quoted URL boundaries

This commit is contained in:
4gray committed 2026-09-21 04:45:54 +02:00
1 parent 802d3dc43c
commit 7b4a49821b
4 files changed
+41 -4

No files matched your search

+2 -2
View File
@@ -66,7 +66,7 @@ The first active HTML base href sets reference resolution, including nested srcd
Resolved file URLs use native filesystem conversion, including Windows drive paths.
Explicit srcset attributes must contain at least one parsed candidate.
Image and media references require a nonempty path that resolves to a file, not a directory.
Direct file URLs are rejected; use portable repository-relative paths.
Direct file URLs and file-scheme HTML bases are rejected; use portable repository-relative paths.
Image references check file existence without interpreting image fragments as
Markdown headings; document links keep anchor checks even when sharing a target.
HTML video, audio, source and track `src` assets and video posters use the same
@@ -77,7 +77,7 @@ At-signs inside external URIs (including explicit opaque autolinks such as mailt
per HTML text node, preserving adjacent imports. A colon directly before an import
does not make that import a URI. Opaque schemes are excluded only in parsed links
whose visible text equals their URI, so colon-labeled prose remains checked.
A closing bracket followed by punctuation and an at-sign terminates a bare URL exclusion.
A closing bracket or quote followed by punctuation and an at-sign terminates a bare URL exclusion.
Extensionless inline candidates are also imports when they resolve to repository files,
checking the full filename before prefixes at ASCII/Unicode prose separators,
including opening parentheses, brackets and braces. Each at-sign candidate is
+1 -1
View File
@@ -163,7 +163,7 @@ export function guidanceProse(markdown) {
}
if (node.nodeName === '#text')
return node.value.replace(
/(?:\b[a-z][a-z\d+.-]*:\/\/|\/\/)[^\s]*?(?=[)\]}>][.,;:!?]*@|\s|$)/giu,
/(?:\b[a-z][a-z\d+.-]*:\/\/|\/\/)[^\s]*?(?=[)\]}>"'”’][.,;:!?]*@|\s|$)/giu,
' '
);
const content = (node.childNodes ?? []).map(text).join('');
+5 -1
View File
@@ -48,7 +48,11 @@ async function validateReference(
if (bases?.length) {
try {
let base = pathToFileURL(resolve(rootDir, source));
for (const href of bases) base = new URL(href, base);
for (const href of bases) {
if (/^file:/iu.test(href.replace(/[\t\n\r]/gu, '').trimStart()))
return `${source}: use a repository-relative HTML base instead of a file URL`;
base = new URL(href, base);
}
const url = new URL(target, base);
if (url.protocol !== 'file:') return;
resolvedBasePath = fileURLToPath(url);
@@ -1597,3 +1597,36 @@ for (const markup of [
assert.ok((await diagnostics(t, { 'AGENTS.md': markup })).length > 0);
});
}
for (const quote of ['"', "'", '”']) {
test(`quoted URL preserves adjacent import: ${quote}`, async (t) => {
assert.ok(
(
await diagnostics(t, {
'CLAUDE.md':
'@AGENTS.md\n\nVisit ' +
quote +
'https://example.com/path' +
quote +
'.@docs/guide.md',
})
).some((message) => message.includes('additional or inline'))
);
});
}
for (const embedded of [false, true]) {
test(`file URL base is rejected: embedded=${embedded}`, async (t) => {
const rootDir = await fixture(t);
const { pathToFileURL } = await import('node:url');
const { realpath } = await import('node:fs/promises');
const base = pathToFileURL((await realpath(rootDir)) + '/').href;
const html = `<base href='${base}'><a href='docs/example.md'>Guide</a>`;
await writeFile(
join(rootDir, 'AGENTS.md'),
embedded ? `<iframe srcdoc="${html}"></iframe>` : html
);
assert.ok(
(await validateAgentGuidance({ rootDir })).diagnostics.length > 0
);
});
}