Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 9 additions & 9 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"type": "module",
"dependencies": {
"commander": "^15.0.0",
"puppeteer-core": "^25.12.0"
"puppeteer-core": "^25.13.0"
}
}
4 changes: 2 additions & 2 deletions skills/codex-huge-context/scripts/preflight.test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions skills/nano-banana-pro/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
53 changes: 44 additions & 9 deletions skills/nano-banana-pro/scripts/generate_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import argparse
import os
import sys
from contextlib import ExitStack, contextmanager
from pathlib import Path


Expand All @@ -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)"
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Expand All @@ -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)
Expand Down
172 changes: 172 additions & 0 deletions skills/nano-banana-pro/scripts/test_generate_image.py
Original file line number Diff line number Diff line change
@@ -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()
Loading