fix(core): reject Windows-style workspace artifact paths (#6483)

Reject Windows-style absolute and traversal paths in record_artifact workspacePath validation by checking portable slash-normalized path semantics. This keeps artifact metadata aligned with the workspace-relative locator contract while preserving valid relative paths.

Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
VectorPeak 2026-07-08 11:29:12 +08:00 committed by GitHub
parent 58e51eb96c
commit e83d548cd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 59 additions and 12 deletions

View file

@ -93,15 +93,58 @@ describe('RecordArtifactTool', () => {
).toThrow(/exactly one/);
});
it('rejects workspace traversal and unsafe urls before reporting success', () => {
it('rejects workspace paths that escape the workspace', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({
title: 'Escape',
workspacePath: '../secret.txt',
}),
).toThrow(/workspacePath/);
for (const workspacePath of [
'../secret.txt',
'..\\secret.txt',
'..\\..\\secret.txt',
'reports\\..\\..\\secret.txt',
'reports/..\\..\\secret.txt',
'C:\\tmp\\report.html',
'C:/tmp/report.html',
'C:tmp\\report.html',
'\\\\server\\share\\report.html',
'\\tmp\\report.html',
]) {
expect(() =>
tool.build({
title: 'Escape',
workspacePath,
}),
).toThrow(/workspacePath/);
}
});
it('accepts safe workspace-relative artifact paths', async () => {
const tool = new RecordArtifactTool();
await expect(
tool
.build({
title: 'Safe report',
workspacePath: 'reports/summary.html',
})
.execute(signal),
).resolves.toMatchObject({
artifacts: [{ workspacePath: 'reports/summary.html' }],
});
await expect(
tool
.build({
title: 'Windows-style relative report',
workspacePath: 'reports\\summary.html',
})
.execute(signal),
).resolves.toMatchObject({
artifacts: [{ workspacePath: 'reports\\summary.html' }],
});
});
it('rejects unsafe urls before reporting success', () => {
const tool = new RecordArtifactTool();
expect(() =>
tool.build({

View file

@ -360,14 +360,18 @@ function validateWorkspacePath(value: string): string | null {
if (stringError) {
return stringError;
}
if (path.isAbsolute(trimmed)) {
if (
path.isAbsolute(trimmed) ||
path.win32.isAbsolute(trimmed) ||
/^[A-Za-z]:/.test(trimmed)
) {
return '"workspacePath" must be relative to the workspace';
}
const normalized = path.normalize(trimmed);
const portableNormalized = path.posix.normalize(trimmed.replace(/\\/g, '/'));
if (
normalized === '..' ||
normalized.startsWith(`..${path.sep}`) ||
path.isAbsolute(normalized)
portableNormalized === '..' ||
portableNormalized.startsWith('../') ||
path.posix.isAbsolute(portableNormalized)
) {
return '"workspacePath" must stay inside the workspace';
}