DNS resolution was previously unrestricted - only the resulting IP
connections were firewalled - so a compromised session could still
exfiltrate data by encoding it into query names to an attacker-controlled
domain. dns-proxy.sh now runs a local dnsmasq with no fallback resolver;
only allowlisted names (shared with init-firewall.sh via fixed-domains.sh)
get forwarded, everything else is refused outright.
IPv6 is blocked outright via ip6tables DROP-all, since there was no IPv6
equivalent of the IPv4 allowlist.
If LOKI_URL is set, every DNS query and every new outbound connection
attempt (allowed or rejected, via NFLOG + ulogd2) ships to Loki as JSON,
for visibility in the existing Grafana/Prometheus setup.
Also fixes two bugs found along the way:
- init-firewall.sh's script-wide IFS (set for stricter splitting elsewhere)
silently broke EXTRA_ALLOWED_DOMAINS whenever it held more than one
space-separated entry, collapsing it into a single bogus token.
- bin/claude-sandbox-allow interpolated the domain argument directly into a
bash -c string executed via docker exec - a command injection path,
since sandbox-context.sh tells a sandboxed agent to hand this exact
command to the user to copy-paste. Fixed with upfront domain validation
and positional-argument passing instead of string interpolation.
Nvim isn't a single process attached to the terminal: the process that
actually spawns jobs (clipboard-copy included) is a separate `nvim --embed`
core process with no tty of its own (ps shows `?`); the real pty belongs to
a distinct parent UI process. Checking only the immediate parent's tty (as
the previous fix did) found the embed process's `?` and fell through to the
broken /dev/tty path every time in real interactive use, even though it
looked fine in headless testing. Walk up the process tree past any tty-less
ancestors instead.
Verified against the real docker-compose path in actual (non-headless) TUI
mode, with real keystrokes fed through a pty: the OSC 52 sequence now
reaches the terminal with no error, where it previously still failed.
- clipboard-copy: nvim's job-spawned clipboard provider process doesn't get
its own controlling terminal, so /dev/tty (ENXIO) fails. Resolve the real
tty from the parent (nvim)'s recorded tty via ps instead.
- entrypoint.sh: chown the allocated pty to node before dropping privileges.
A normal login/ssh session always does this; this entrypoint skipped it,
leaving the tty root-owned so node couldn't write to it directly even
after resolving the right path.
- init-firewall.sh/README: allow release-assets.githubusercontent.com.
GitHub release assets (e.g. mason-registry's own index) redirect here,
a separate CDN from github.com's published IP ranges - without it mason
can't even look up a package's install spec, regardless of PyPI access.
All three verified against a rebuilt image: OSC 52 write reaches the real
pty with no error, and mason's registry refresh succeeds through the
firewall.
pypi.org and files.pythonhosted.org share Fastly edge IPs, so the second
domain's ipset add hit a duplicate-element error. Without -exist, that
error plus set -e killed the whole firewall init (and thus the container)
before nvim ever started. Reported by the user as a silent hang after a
successful build.
- Bind-mount dotfiles at node's own $HOME/dotfiles too, not just the
host-path mount needed for the ~/.claude/hooks symlink: node's $HOME is
/home/node, which broke clipboard-copy and anything else in this repo
assuming ~/dotfiles.
- Forward the host's real TERM into the container instead of letting
Docker's tty allocation default to a generic xterm, which was breaking
the Ctrl-H/Backspace disambiguation the tmux config sets up.
- Add pypi.org/files.pythonhosted.org to the firewall's default allowlist
so mason's pip-installed Python tools (ruff, basedpyright, debugpy) can
actually install; verified with a real venv pip install.
The lookup was matching on head.ref alone, so running create with a
different --base for the same branch would silently return an
unrelated PR's URL instead of opening a new one. Resolve base before
the check and filter on both head.ref and base.ref.
gitea-pr create now checks for an existing open PR on the current
branch first and returns its URL instead of creating a new one. Only
matches open PRs by design -- once merged, new commits on the same
branch correctly get a new PR.