diff --git a/tests/test_site_agents.py b/tests/test_site_agents.py index 38558e7a4..c3d257f6d 100644 --- a/tests/test_site_agents.py +++ b/tests/test_site_agents.py @@ -84,6 +84,15 @@ class TheToolsReadOnly(unittest.TestCase): self.assertIn("const games = everything ? [] :", self.source) self.assertIn("'--required-only'", self.source) + def test_a_value_that_could_escape_the_command_is_refused(self) -> None: + """The command is pasted into a shell: PowerShell also ends a single-quoted + string on the typographic quotes, and a path starting with "-" reads as + an option.""" + self.assertIn("const unsafe = /[\\u0000-\\u001f\\u007f'\"`‘’‚‛“”„]/;", self.source) + self.assertIn("value.startsWith('-')", self.source) + self.assertIn("/^[a-z0-9_-]+$/.test(platform)", self.source) + self.assertIn("`--dest=${quote(bios_folder as string)}`", self.source) + def test_they_reach_no_other_site(self) -> None: code = re.sub(r"^\s*//.*$", "", self.source, flags=re.M) self.assertEqual(set(re.findall(r"https://[\w.-]+", code)), {"https://abdess.github.io"}) diff --git a/website/src/scripts/agent-tools.ts b/website/src/scripts/agent-tools.ts index 10d92b84b..51de884a1 100644 --- a/website/src/scripts/agent-tools.ts +++ b/website/src/scripts/agent-tools.ts @@ -141,12 +141,25 @@ const setupCommand: Tool = { }, annotations: { readOnlyHint: true }, async execute({ shell, everything, bios_folder, games_folder, platform }) { - const quote = (value: unknown) => `'${String(value).replace(/'/g, shell === 'powershell' ? "''" : "'\\''")}'`; - const run = shell === 'powershell' ? PS : SH; + const powershell = shell === 'powershell'; + // The command is pasted into a shell: a value that could close its + // quotes (PowerShell also ends a string on the typographic ones) or + // start another option is refused, never escaped. + const unsafe = /[\u0000-\u001f\u007f'"`‘’‚‛“”„]/; + for (const [field, value] of [['bios_folder', bios_folder], ['games_folder', games_folder]] as const) { + if (value !== undefined && (typeof value !== 'string' || !value || unsafe.test(value) || value.startsWith('-'))) { + return `${field} must be a folder path without quotes or control characters.`; + } + } + if (platform !== undefined && (typeof platform !== 'string' || !/^[a-z0-9_-]+$/.test(platform))) { + return 'platform must be a setup name such as retroarch or batocera.'; + } + const run = powershell ? PS : SH; + const quote = (value: string) => `'${value}'`; const place: string[] = []; - if (platform) place.push('--platform', quote(platform)); - if (bios_folder) place.push('--dest', quote(bios_folder)); - const games = everything ? [] : [...(games_folder ? ['--for-games', quote(games_folder)] : ['--for-games']), '--required-only']; + if (platform) place.push(`--platform=${platform}`); + if (bios_folder) place.push(`--dest=${quote(bios_folder as string)}`); + const games = everything ? [] : [games_folder ? `--for-games=${quote(games_folder as string)}` : '--for-games', '--required-only']; const command = (...more: string[]) => [run, ...place, ...more].join(' '); return JSON.stringify({ preview: command(...games, '--dry-run'),