diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bd27b5c..429a9972 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,12 @@ jobs: - name: Test maintainer orchestrator worker boundary run: scripts/test-maintainer-orchestrator-policy + - name: Test image output safety (offline, synthetic) + run: | + python3 -m venv "$RUNNER_TEMP/image-tests" + "$RUNNER_TEMP/image-tests/bin/python" -m pip install 'pillow>=10.0.0' + "$RUNNER_TEMP/image-tests/bin/python" -m unittest discover -s skills/nano-banana-pro/scripts -p 'test_*.py' + - name: Build browser helper run: | bun install --frozen-lockfile diff --git a/CHANGELOG.md b/CHANGELOG.md index e3bbecb3..2090a92c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ summary: Timeline of guardrail helper changes mirrored from Sweetistics and rela ## Unreleased +- Update Puppeteer Core to 25.13.0 with its matching browser protocol dependencies; keep the Node.js runtime floor unchanged. + +- Prevent Nano Banana image outputs from following symlinks or overwriting existing files, including through raced parent paths; retain explicit output directories. Thanks @DillyRabbit! (#46) + +- Keep the Codex preflight test catalogue aligned with supported models and verify that every required catalogue entry is enforced. + - Default `codex-first` workers and autoreviews to GPT-6.1 Sol with high reasoning on the Ultrafast tier (Fast is now the opt-in); add 6.1 Sol to the `codex-huge-context` catalogue policy and preflight. - Keep Codex workers on Astra with high reasoning and Fast/priority by default; document Ultrafast as an explicit per-launch option without changing saved defaults. diff --git a/bun.lock b/bun.lock index a92c556d..a4c5cfeb 100644 --- a/bun.lock +++ b/bun.lock @@ -6,24 +6,24 @@ "name": "agent-scripts-tools", "dependencies": { "commander": "^15.0.0", - "puppeteer-core": "^25.12.0", + "puppeteer-core": "^25.13.0", }, }, }, "packages": { - "@puppeteer/browsers": ["@puppeteer/browsers@3.2.3", "", { "dependencies": { "modern-tar": "^0.8.4", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-2Bt3m6dDAJqmZehn0wiXSYyQmuhyiHHZ7FjvrVaS1W2RoFGV6eCK71UvKYkNJjkwC1Q9nobjhvTMhLQztFVINA=="], + "@puppeteer/browsers": ["@puppeteer/browsers@3.2.4", "", { "dependencies": { "modern-tar": "^0.8.5", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-okJ6yQMc0SBexOKxMi966omwAV1uAPwgF/YMZdb04gZQPXVjWHl9bmXjZYtIcsN1+PN3Bz4iFfGK3g+c6lZJ0Q=="], "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "chromium-bidi": ["chromium-bidi@17.0.2", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg=="], + "chromium-bidi": ["chromium-bidi@157.0.8090-0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^4.6.5" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-KJw7IQgqXwxpJgJlEIPg5mr2ZoqA+jkTRtc5EABI7sUJ3bu3cpI7NuEBoj4Gn1AsF78CaT9So5WQWspK3/wYag=="], "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], - "devtools-protocol": ["devtools-protocol@0.0.1687809", "", {}, "sha512-t7kSb+UKYdyxcmqy98U2P86ne6rCve3DIJ4+lp54i/hhClgFuG35JrLj23rO14Jw3sWDsp7i+22QhNU0+/qk8g=="], + "devtools-protocol": ["devtools-protocol@0.0.1696802", "", {}, "sha512-FO5/SENfvc2K8wRnWQflKcwuoTH4dBkXCWTQ9ebIWtuX8JM5+ZvCotaEmkfh21pO7TvIKGTM3hG4xFiq9WTwmg=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -35,17 +35,17 @@ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], - "modern-tar": ["modern-tar@0.8.4", "", {}, "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g=="], + "modern-tar": ["modern-tar@0.8.5", "", {}, "sha512-snEhs+6G5Tjd4I7tLCDOaoln2RgE0bD19RzEKgvgK2hZ5VKy3MpLhLTZ2fWpXSTg4K2cyPwp+VHATFJhxfnOeA=="], - "puppeteer-core": ["puppeteer-core@25.12.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.3", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1687809", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.3", "ws": "^8.21.3" } }, "sha512-z6LQUt5SH7jwGtGA+nMtJuEjZIsqPiwQ+EFHCYuVDQrJpvifNmmGuyDvTzAKPyFzihxgHc5f3aMzu2kn8Ps37A=="], + "puppeteer-core": ["puppeteer-core@25.13.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.4", "chromium-bidi": "157.0.8090-0", "devtools-protocol": "0.0.1696802", "typed-query-selector": "^2.12.3", "webdriver-bidi-protocol": "1.0.1", "ws": "^8.21.3" } }, "sha512-cyBS0OEx3e9NS53B5F6+UazXtR7f9Tw0cR1/FbPW59/hV6JPZbkIiMnxK2543IkFp3rsGlLn1iPDLF77TJsJsA=="], "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="], + "typed-query-selector": ["typed-query-selector@2.12.3", "", {}, "sha512-/dtuMZyutHLlrxWhhC1uib00ELUr2dZ8C9B8YzZYsJmeE3V56OxRblrSM2UaF142pcMtak7ehZ6+poWGeehNIg=="], - "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.3", "", {}, "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@1.0.1", "", {}, "sha512-WXWs/aXF2a6Al1Z2/V/7Gp2oPZOAAmHjJOTmv4KCXRJ122IMj3tSSMc6a4q3K4QOmhQbse/Ahr+oTVJOU2dUiw=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -57,7 +57,7 @@ "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], diff --git a/package.json b/package.json index 99fd8847..3ae29aa8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "type": "module", "dependencies": { "commander": "^15.0.0", - "puppeteer-core": "^25.12.0" + "puppeteer-core": "^25.13.0" } } diff --git a/skills/codex-huge-context/scripts/preflight.test.rb b/skills/codex-huge-context/scripts/preflight.test.rb index d5fb9d40..e95419eb 100644 --- a/skills/codex-huge-context/scripts/preflight.test.rb +++ b/skills/codex-huge-context/scripts/preflight.test.rb @@ -9,7 +9,7 @@ require "tmpdir" SCRIPT = File.expand_path("preflight.rb", __dir__) -MODELS = %w[gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-6-astra].freeze +MODELS = %w[gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-6-astra gpt-6.1-sol].freeze CONTEXT_WINDOW = 922_000 AUTO_COMPACT_TOKEN_LIMIT = 700_000 OUTPUT_SENTINEL = "fixture-output-must-not-appear" @@ -187,7 +187,7 @@ def run_preflight(config, *arguments) end end -%w[gpt-5.6-luna gpt-6-astra].each do |missing_model| +MODELS.each do |missing_model| Dir.mktmpdir("codex-huge-context-test") do |root| config = write_fixture( root, diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md index f96a11c6..7e0d9766 100644 --- a/skills/nano-banana-pro/SKILL.md +++ b/skills/nano-banana-pro/SKILL.md @@ -116,9 +116,12 @@ Use templates when the user is vague or when edits must be precise. ## Output - Saves PNG to current directory (or specified path if filename includes directory) +- Requires a new filename: existing files, hard links, and symlinks are rejected. Parent directories are created as needed, but symlinked path components are rejected; use their real directory paths instead. Absolute paths and paths outside the current directory remain supported on macOS and Linux. - Script outputs the full path to the generated image - **Do not read the image back** - just inform the user of the saved path +Offline output-safety regression tests (Pillow required): `python3 -m unittest discover -s skills/nano-banana-pro/scripts -p 'test_*.py'` from the repository root. + ## Examples **Generate new image:** diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index c78d6977..33de1c42 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -16,6 +16,7 @@ import argparse import os import sys +from contextlib import ExitStack, contextmanager from pathlib import Path @@ -39,6 +40,40 @@ def normalize_resolution(value: str) -> str: return normalized +@contextmanager +def create_output(path: Path): + """Create a new output without following leaf or parent symlinks.""" + if not path.name or path.name == "..": + raise ValueError("Output must name a new file") + if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"): + raise OSError("Safe image output requires POSIX directory handles") + + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + with ExitStack() as stack: + directory = os.open(path.anchor or ".", directory_flags) + stack.callback(os.close, directory) + parents = path.parts[1:-1] if path.is_absolute() else path.parts[:-1] + for component in parents: + try: + child = os.open(component, directory_flags, dir_fd=directory) + except FileNotFoundError: + try: + os.mkdir(component, dir_fd=directory) + except FileExistsError: + pass + child = os.open(component, directory_flags, dir_fd=directory) + stack.callback(os.close, child) + directory = child + + # Exclusive creation rejects existing files, hard links and dangling links. + descriptor = os.open( + path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o666, dir_fd=directory, + ) + with os.fdopen(descriptor, "wb") as output: + yield output + + def main(): parser = argparse.ArgumentParser( description="Generate images using Nano Banana 2 (Gemini 3.1 Flash Image)" @@ -51,7 +86,7 @@ def main(): parser.add_argument( "--filename", "-f", required=True, - help="Output filename (e.g., sunset-mountains.png)" + help="New output filename; existing files and symlink paths are rejected" ) parser.add_argument( "--input-image", "-i", @@ -90,7 +125,6 @@ def main(): # Set up output path output_path = Path(args.filename) - output_path.parent.mkdir(parents=True, exist_ok=True) # Load input image if provided input_image = None @@ -137,7 +171,7 @@ def main(): ) # Process response and convert to PNG - image_saved = False + output_image = None for part in response.parts: if part.text is not None: print(f"Model response: {part.text}") @@ -158,15 +192,16 @@ def main(): if image.mode == 'RGBA': rgb_image = PILImage.new('RGB', image.size, (255, 255, 255)) rgb_image.paste(image, mask=image.split()[3]) - rgb_image.save(str(output_path), 'PNG') + output_image = rgb_image elif image.mode == 'RGB': - image.save(str(output_path), 'PNG') + output_image = image else: - image.convert('RGB').save(str(output_path), 'PNG') - image_saved = True + output_image = image.convert('RGB') - if image_saved: - full_path = output_path.resolve() + if output_image is not None: + with create_output(output_path) as output: + output_image.save(output, 'PNG') + full_path = output_path.absolute() print(f"\nImage saved: {full_path}") else: print("Error: No image was generated in the response.", file=sys.stderr) diff --git a/skills/nano-banana-pro/scripts/test_generate_image.py b/skills/nano-banana-pro/scripts/test_generate_image.py new file mode 100644 index 00000000..c0cee8c8 --- /dev/null +++ b/skills/nano-banana-pro/scripts/test_generate_image.py @@ -0,0 +1,172 @@ +"""Offline CLI regression tests with real Pillow and synthetic Gemini responses.""" + +import base64 +import importlib.util +import io +import os +from pathlib import Path +import sys +import tempfile +import types +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest.mock import patch + +from PIL import Image + + +spec = importlib.util.spec_from_file_location( + "generate_image", Path(__file__).with_name("generate_image.py") +) +generator = importlib.util.module_from_spec(spec) +spec.loader.exec_module(generator) + + +class OutputTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + self.workspace = self.root / "workspace" + self.workspace.mkdir() + self.victim = self.root / "victim.txt" + self.victim.write_bytes(b"SAFE SENTINEL\n") + self.original_open = os.open + + def run_cli(self, output, modes=("RGB",), encoded=False): + parts = [types.SimpleNamespace(text="synthetic response", inline_data=None)] + for mode in modes: + data = io.BytesIO() + Image.new(mode, (2, 2)).save(data, "PNG") + payload = data.getvalue() + if encoded: + payload = base64.b64encode(payload).decode("ascii") + parts.append(types.SimpleNamespace( + text=None, inline_data=types.SimpleNamespace(data=payload) + )) + client = types.SimpleNamespace(models=types.SimpleNamespace( + generate_content=lambda **kwargs: types.SimpleNamespace(parts=parts) + )) + genai = types.ModuleType("google.genai") + genai.Client = lambda **kwargs: client + genai.types = types.SimpleNamespace( + GenerateContentConfig=lambda **kwargs: kwargs, + ImageConfig=lambda **kwargs: kwargs, + ) + google = types.ModuleType("google") + google.genai = genai + output_log = io.StringIO() + with patch.dict(sys.modules, {"google": google, "google.genai": genai}), \ + patch.object(sys, "argv", ["generate_image.py", "--prompt", "fixture", + "--api-key", "synthetic", "--filename", str(output)]), \ + redirect_stdout(output_log), redirect_stderr(output_log): + try: + generator.main() + except SystemExit as error: + return error.code, output_log.getvalue() + return 0, output_log.getvalue() + + def assert_rejected(self, output): + code, log = self.run_cli(output) + self.assertEqual(code, 1, log) + self.assertNotIn("Image saved:", log) + self.assertEqual(self.victim.read_bytes(), b"SAFE SENTINEL\n") + + def test_output_symlink_does_not_overwrite_target(self): + output = self.workspace / "result.png" + output.symlink_to(self.victim) + self.assert_rejected(output) + self.assertTrue(output.is_symlink()) + + def test_dangling_output_symlink_does_not_create_target(self): + missing = self.root / "missing.txt" + output = self.workspace / "result.png" + output.symlink_to(missing) + self.assert_rejected(output) + self.assertFalse(missing.exists()) + + def test_parent_symlink_is_rejected(self): + (self.workspace / "linked").symlink_to(self.root, target_is_directory=True) + self.assert_rejected(self.workspace / "linked" / "victim.txt") + + def test_regular_file_and_hard_link_are_preserved(self): + output = self.workspace / "result.png" + os.link(self.victim, output) + self.assert_rejected(output) + self.assert_rejected(self.victim) + + def test_leaf_replaced_with_symlink_at_open_is_rejected(self): + output = self.workspace / "result.png" + + def race(path, flags, *args, **kwargs): + if flags & os.O_CREAT: + output.symlink_to(self.victim) + return self.original_open(path, flags, *args, **kwargs) + + with patch.object(generator.os, "open", side_effect=race): + self.assert_rejected(output) + + def test_parent_replaced_before_open_is_rejected(self): + parent = self.workspace / "nested" + parent.mkdir() + + def race(path, flags, *args, **kwargs): + if path == "nested": + parent.rmdir() + parent.symlink_to(self.root, target_is_directory=True) + return self.original_open(path, flags, *args, **kwargs) + + with patch.object(generator.os, "open", side_effect=race): + self.assert_rejected(parent / "victim.txt") + + def test_open_parent_stays_pinned_when_path_is_replaced(self): + parent = self.workspace / "nested" + parent.mkdir() + moved = self.workspace / "moved" + + def race(path, flags, *args, **kwargs): + if flags & os.O_CREAT: + parent.rename(moved) + parent.symlink_to(self.root, target_is_directory=True) + return self.original_open(path, flags, *args, **kwargs) + + with patch.object(generator.os, "open", side_effect=race): + code, log = self.run_cli(parent / "result.png") + self.assertEqual(code, 0, log) + self.assertTrue((moved / "result.png").is_file()) + self.assertFalse((self.root / "result.png").exists()) + + def test_new_directories_explicit_paths_and_image_modes(self): + for mode in ("RGB", "RGBA", "L"): + with self.subTest(mode=mode): + output = self.root / "explicit" / mode / "result.png" + code, log = self.run_cli(output, modes=(mode,), encoded=mode == "L") + self.assertEqual(code, 0, log) + with Image.open(output) as image: + self.assertEqual(image.format, "PNG") + self.assertEqual(image.mode, "RGB") + self.assertEqual(image.size, (2, 2)) + + def test_multiple_images_keep_last_response(self): + output = self.workspace / "result.png" + code, log = self.run_cli(output, modes=("RGB", "RGBA")) + self.assertEqual(code, 0, log) + with Image.open(output) as image: + self.assertEqual(image.getpixel((0, 0)), (255, 255, 255)) + + def test_relative_output_and_missing_image(self): + previous = Path.cwd() + try: + os.chdir(self.workspace) + code, log = self.run_cli(Path("nested/result.png")) + self.assertEqual(code, 0, log) + self.assertTrue((self.workspace / "nested/result.png").exists()) + finally: + os.chdir(previous) + code, log = self.run_cli(self.workspace / "absent.png", modes=()) + self.assertEqual(code, 1, log) + self.assertFalse((self.workspace / "absent.png").exists()) + + +if __name__ == "__main__": + unittest.main()