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
11 changes: 8 additions & 3 deletions .github/workflows/action-json-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ on:
type: string
default: "**/*.action.json"
autocontrol_ref:
description: "Pip spec for je_auto_control (e.g. == 0.1.0 or git+https://...)."
description: "Pip spec for je_auto_control (e.g. ==0.1.0 or git+https://...)."
required: false
type: string
default: "je_auto_control"
# Pinned by default so a downstream lint run is reproducible; pass
# your own spec to track a different release or a git ref.
default: "je_auto_control==0.0.214"

jobs:
lint:
Expand All @@ -36,7 +38,10 @@ jobs:
env:
AUTOCONTROL_REF: ${{ inputs.autocontrol_ref }}
run: |
python -m pip install --upgrade pip
python -m pip install --only-binary :all: --upgrade "pip==26.0.1"
# The spec is a documented workflow input — pinned by default so a
# lint run is reproducible, overridable so a caller can track their
# own release or a git ref.
python -m pip install "$AUTOCONTROL_REF"

- name: Lint action JSON files
Expand Down
26 changes: 21 additions & 5 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
# Build: docker build -f docker/Dockerfile -t autocontrol:latest .
# Run: docker run --rm -p 9939:9939 -p 9940:9940 autocontrol:latest

# Build every wheel — the project and its dependencies — in a throwaway
# stage. The runtime image then installs binaries only, so no dependency
# gets to execute a setup script there and the resolved set is fixed at
# build time instead of being re-resolved against PyPI.
FROM python:3.12-slim AS builder

WORKDIR /src
COPY pyproject.toml README.md ./
COPY je_auto_control ./je_auto_control
COPY autocontrol-lsp ./autocontrol-lsp
RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \
&& pip wheel --no-cache-dir --wheel-dir /wheels .


FROM python:3.12-slim AS runtime

ARG DEBIAN_FRONTEND=noninteractive
Expand Down Expand Up @@ -37,11 +51,13 @@ COPY je_auto_control ./je_auto_control
COPY autocontrol-lsp ./autocontrol-lsp
COPY README.md ./

# Install the package + the [webrtc] extra so the optional WebRTC host
# also works inside the container. Pin pip first to keep layer churn
# minimal across CI rebuilds.
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -e .
# Install from the wheels built above: pinned pip, wheels only, and no
# index lookup, so the runtime layer cannot pull anything new from PyPI.
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \
&& pip install --no-cache-dir --only-binary :all: --no-index \
--find-links=/wheels /wheels/je_auto_control-*.whl \
&& rm -rf /wheels

ENV DISPLAY=:99 \
PYTHONUNBUFFERED=1 \
Expand Down
19 changes: 17 additions & 2 deletions docker/Dockerfile.xfce
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
# Connect a VNC viewer to localhost:5900 (no password by default; set
# AUTOCONTROL_VNC_PASSWORD to enable a TightVNC password).

# Same two-stage build as docker/Dockerfile: wheels are produced here so
# the runtime layer installs binaries only, with no index lookup.
FROM python:3.12-slim AS builder

WORKDIR /src
COPY pyproject.toml README.md ./
COPY je_auto_control ./je_auto_control
COPY autocontrol-lsp ./autocontrol-lsp
RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \
&& pip wheel --no-cache-dir --wheel-dir /wheels .


FROM python:3.12-slim AS runtime

ARG DEBIAN_FRONTEND=noninteractive
Expand All @@ -39,8 +51,11 @@ COPY je_auto_control ./je_auto_control
COPY autocontrol-lsp ./autocontrol-lsp
COPY README.md ./

RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -e .
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --only-binary :all: --upgrade "pip==26.0.1" \
&& pip install --no-cache-dir --only-binary :all: --no-index \
--find-links=/wheels /wheels/je_auto_control-*.whl \
&& rm -rf /wheels

ENV DISPLAY=:99 \
PYTHONUNBUFFERED=1 \
Expand Down
25 changes: 15 additions & 10 deletions je_auto_control/utils/config_bundle/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
ConfigBundleError, default_bundle_root, export_config_bundle,
import_config_bundle,
)
from je_auto_control.utils.path_guard.path_guard import (
PathNotAllowedError, validate_path,
)


def _build_arg_parser() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -38,22 +41,24 @@

def main(argv: Optional[list] = None) -> int:
args = _build_arg_parser().parse_args(argv)
if args.action == "export":
return _do_export(args.output, args.root)
return _do_import(args.input, args.root, args.dry_run)
try:
if args.action == "export":
return _do_export(validate_path(args.output), args.root)
return _do_import(validate_path(args.input, must_exist=True),
args.root, args.dry_run)
except PathNotAllowedError as error:
print(f"refusing to use that path: {error}", file=sys.stderr)
return 2


def _do_export(output: Path, root: Optional[Path]) -> int:
bundle = export_config_bundle(root=root)
output.parent.mkdir(parents=True, exist_ok=True)
# The output path comes from argv on a CLI entry point. The operator
# running ``python -m ... export <file>`` is the trust boundary;
# restricting where they can write would break the documented
# export workflow.
output.write_text( # NOSONAR — operator-controlled CLI argument by design (see comment above)
# ``output`` was canonicalised and bounded by validate_path() in main().
output.write_text(
json.dumps(bundle, ensure_ascii=False, indent=2),
encoding="utf-8",
)

Check failure on line 61 in je_auto_control/utils/config_bundle/__main__.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not construct the path from user-controlled data.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ-NR25zkL2BOW0UE631&open=AZ-NR25zkL2BOW0UE631&pullRequest=462
print(f"Wrote bundle to {output.resolve()}")
print(f" source root: {bundle['manifest']['source_root']}")
print(f" files included: {len(bundle['files'])}")
Expand All @@ -64,8 +69,8 @@

def _do_import(source: Path, root: Optional[Path], dry_run: bool) -> int:
try:
# source is an operator-supplied CLI path, not remote input
bundle = json.loads(source.read_text(encoding="utf-8")) # NOSONAR
# ``source`` was canonicalised and bounded by validate_path() in main().
bundle = json.loads(source.read_text(encoding="utf-8"))
except (OSError, ValueError) as error:
print(f"failed to read {source}: {error}", file=sys.stderr)
return 2
Expand Down
10 changes: 10 additions & 0 deletions je_auto_control/utils/path_guard/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Canonicalise and bound filesystem paths supplied on the command line."""
from je_auto_control.utils.path_guard.path_guard import (
ALLOWED_ROOTS_ENV, PathNotAllowedError, default_allowed_roots,
validate_path,
)

__all__ = [
"ALLOWED_ROOTS_ENV", "PathNotAllowedError", "default_allowed_roots",
"validate_path",
]
89 changes: 89 additions & 0 deletions je_auto_control/utils/path_guard/path_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Canonicalise and bound filesystem paths that arrive from a CLI argument.

The ``python -m je_auto_control...`` entry points take output/input paths from
``argv``. A mistaken — or generated — argument such as ``../../etc/passwd``
would otherwise be handed straight to ``write_text``/``mkdir``. Every such
path goes through :func:`validate_path` first: it is canonicalised with
``os.path.realpath`` (so ``..`` and symlinks are resolved before the check)
and must land inside one of the allowed roots.

The default roots are the current working directory, the user's home and the
system temp directory — the places an operator actually exports to. Set
``AUTOCONTROL_ALLOWED_PATH_ROOTS`` (``os.pathsep``-separated) to add more, for
example a mounted volume in a container.

Headless module: imports no PySide6.
"""
from __future__ import annotations

import os
import tempfile
from pathlib import Path
from typing import Iterable, List, Optional, Sequence

from je_auto_control.utils.exception.exceptions import AutoControlException

ALLOWED_ROOTS_ENV = "AUTOCONTROL_ALLOWED_PATH_ROOTS"


class PathNotAllowedError(AutoControlException):
"""A supplied path is malformed or resolves outside the allowed roots."""


def default_allowed_roots() -> List[Path]:
"""Return the roots a CLI path may resolve into, extras from env first."""
roots: List[Path] = []
for entry in os.environ.get(ALLOWED_ROOTS_ENV, "").split(os.pathsep):
if entry.strip():
roots.append(_canonical(entry.strip()))
roots.append(_canonical(Path.cwd()))
roots.append(_canonical(Path.home()))
roots.append(_canonical(tempfile.gettempdir()))
return roots


def validate_path(raw: os.PathLike | str, *,
allowed_roots: Optional[Iterable[os.PathLike | str]] = None,
allowed_suffixes: Optional[Sequence[str]] = None,
must_exist: bool = False) -> Path:
"""Return ``raw`` canonicalised, or raise :class:`PathNotAllowedError`.

``allowed_suffixes`` is matched case-insensitively against the final
suffix. ``must_exist`` additionally requires the resolved path to be
present on disk.
"""
text = os.fspath(raw)
if not text or "\x00" in text:
raise PathNotAllowedError(f"invalid path: {text!r}")
candidate = _canonical(text)
_check_suffix(candidate, allowed_suffixes)
roots = default_allowed_roots() if allowed_roots is None \
else [_canonical(root) for root in allowed_roots]
if not any(_is_within(candidate, root) for root in roots):
raise PathNotAllowedError(
f"{candidate} is outside the allowed roots "
f"({', '.join(str(root) for root in roots)}); "
f"set {ALLOWED_ROOTS_ENV} to permit it")
if must_exist and not candidate.exists():
raise PathNotAllowedError(f"{candidate} does not exist")
return candidate


# --- internals ----------------------------------------------------

def _canonical(value: os.PathLike | str) -> Path:
return Path(os.path.realpath(Path(value).expanduser()))


def _check_suffix(candidate: Path,
allowed_suffixes: Optional[Sequence[str]]) -> None:
if not allowed_suffixes:
return
wanted = {suffix.lower() for suffix in allowed_suffixes}
if candidate.suffix.lower() not in wanted:
raise PathNotAllowedError(
f"{candidate.name} does not end in {' / '.join(sorted(wanted))}")


def _is_within(candidate: Path, root: Path) -> bool:
return candidate == root or root in candidate.parents
4 changes: 3 additions & 1 deletion je_auto_control/utils/remote_desktop/connect_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
)

_TCP_SCHEMES = ("tcp://",)
_WS_SCHEME = "ws://"
# Recognised, never defaulted to: a target is classified as ``ws`` only when
# the operator typed the scheme. ``wss://`` is matched first.
_WS_SCHEME = "ws://" # NOSONAR python:S5332 # reason: prefix used to parse operator input, not to open a connection
_WSS_SCHEME = "wss://"
_DIGIT_GROUP_PATTERN = re.compile(r"^[\d\s\-_]+$")
_MIN_PORT = 1
Expand Down
14 changes: 11 additions & 3 deletions je_auto_control/utils/remote_desktop/turn_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
from pathlib import Path
from typing import Optional

from je_auto_control.utils.path_guard.path_guard import (
PathNotAllowedError, validate_path,
)

_DEFAULT_PORT = 3478
_DEFAULT_TLS_PORT = 5349
Expand Down Expand Up @@ -142,32 +145,32 @@
secret: str, listen_port: int, tls_port: int,
tls_cert: Optional[str], tls_key: Optional[str],
external_ip: Optional[str]) -> None:
# output_dir is an operator-supplied CLI path, not remote input
output_dir.mkdir(parents=True, exist_ok=True) # NOSONAR
# CLI callers reach this through main(), which bounds the path first.
output_dir.mkdir(parents=True, exist_ok=True)
conf_path = output_dir / "turnserver.conf"
conf_path.write_text(render_turnserver_conf(
realm=realm, listen_port=listen_port, tls_port=tls_port,
user=user, secret=secret,
tls_cert=tls_cert, tls_key=tls_key,
external_ip=external_ip,
), encoding="utf-8")

Check failure on line 156 in je_auto_control/utils/remote_desktop/turn_config.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

LLMs running this code with faulty CLI arguments can escape file system restrictions. Refactor this code to validate the constructed path before accessing the file system.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ-NR22qkL2BOW0UE63z&open=AZ-NR22qkL2BOW0UE63z&pullRequest=462
(output_dir / "coturn.service").write_text(
render_systemd_unit(conf_path=str(conf_path)),
encoding="utf-8",
)
(output_dir / "docker-compose.yml").write_text(
render_docker_compose(
conf_path=str(conf_path),
listen_port=listen_port, tls_port=tls_port,
),
encoding="utf-8",
)

Check failure on line 167 in je_auto_control/utils/remote_desktop/turn_config.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

LLMs running this code with faulty CLI arguments can escape file system restrictions. Refactor this code to validate the constructed path before accessing the file system.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ-NR22qkL2BOW0UE63y&open=AZ-NR22qkL2BOW0UE63y&pullRequest=462
(output_dir / "README.txt").write_text(
render_readme(realm=realm, listen_port=listen_port, tls_port=tls_port,
user=user, secret=secret,
tls=bool(tls_cert and tls_key)),
encoding="utf-8",
)

Check failure on line 173 in je_auto_control/utils/remote_desktop/turn_config.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

LLMs running this code with faulty CLI arguments can escape file system restrictions. Refactor this code to validate the constructed path before accessing the file system.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ-NR22qkL2BOW0UE630&open=AZ-NR22qkL2BOW0UE630&pullRequest=462


def _build_arg_parser() -> argparse.ArgumentParser:
Expand Down Expand Up @@ -197,9 +200,14 @@

def main(argv: Optional[list] = None) -> int:
args = _build_arg_parser().parse_args(argv)
try:
output_dir = validate_path(args.output_dir)
except PathNotAllowedError as error:
print(f"refusing to write there: {error}", file=sys.stderr)
return 2
secret = args.secret or secrets.token_urlsafe(24)
write_bundle(
args.output_dir,
output_dir,
realm=args.realm, user=args.user, secret=secret,
listen_port=args.listen, tls_port=args.tls_port,
tls_cert=args.tls_cert, tls_key=args.tls_key,
Expand Down
7 changes: 7 additions & 0 deletions je_auto_control/utils/remote_desktop/web_viewer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@
#screen { max-width: 100%; max-height: 100%; cursor: crosshair;
display: block; }
#placeholder { color: #555; font-size: 14px; padding: 24px; text-align: center; }
/* Off-screen but still announced by screen readers and reachable by
`for=` association, for controls whose purpose is shown elsewhere. */
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px;
padding: 0; overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0; }
</style>
</head>
<body>
Expand All @@ -108,7 +113,9 @@
<button id="opus-mic" disabled data-i18n="btn_opus_off">Opus Off</button>
<button id="share-screen" disabled data-i18n="btn_share_off">Share Off</button>
<button id="send-file" disabled data-i18n="btn_send_file">Send file...</button>
<label class="sr-only" for="file-input">Select file to send</label>
<input type="file" id="file-input" hidden aria-label="Select file to send" />
<label class="sr-only" for="lang-select">Language</label>
<select id="lang-select" title="Language" aria-label="Language">
<option value="auto">Auto</option>
<option value="en">English</option>
Expand Down
2 changes: 1 addition & 1 deletion je_auto_control/utils/rest_api/dashboard/swagger.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
</head>
<body>
<div class="ac-token-bar">
<strong>Bearer token</strong>
<label for="ac-token"><strong>Bearer token</strong></label>
<input id="ac-token" type="password" autocomplete="off" aria-label="Bearer token"
placeholder="paste token from the REST API tab" />
<button id="ac-apply" type="button">Apply</button>
Expand Down
15 changes: 10 additions & 5 deletions je_auto_control/utils/stubs/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

from je_auto_control.utils.path_guard.path_guard import (
PathNotAllowedError, validate_path,
)

_HEADER = (
"# AUTOGENERATED by je_auto_control.utils.stubs.generator — do not edit.\n"
"# Regenerate with: python -m je_auto_control.utils.stubs.generator\n"
Expand Down Expand Up @@ -88,8 +92,8 @@ def write_pyi(target: Path,
target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(target.suffix + ".tmp")
tmp.write_text(body, encoding="utf-8")
# target is an operator-supplied CLI path, not remote input
tmp.replace(target) # NOSONAR
# CLI callers reach this through _cli(), which bounds the path first.
tmp.replace(target)
return target


Expand Down Expand Up @@ -192,11 +196,12 @@ def _cli(argv: Optional[List[str]] = None) -> int:
signatures = collect_signatures()
body = render_pyi(signatures)
if args.path:
write_pyi(Path(args.path), signatures)
print(f"wrote {len(signatures)} signatures to {args.path}")
target = validate_path(args.path, allowed_suffixes=(".pyi",))
write_pyi(target, signatures)
print(f"wrote {len(signatures)} signatures to {target}")
else:
sys.stdout.write(body)
except (OSError, ValueError, TypeError) as error:
except (OSError, ValueError, TypeError, PathNotAllowedError) as error:
print(f"stub generation failed: {error}", file=sys.stderr)
return 1
return 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
print(keyboard_keys_table.keys())
press_keyboard_key("shift")
write("123456789")
assert write("abcdefghijklmnopqrstuvwxyz") == "abcdefghijklmnopqrstuvwxyz" # noqa: S101 # reason: integration test
# SystemExit is not an Exception, so a mismatch escapes the enclosing
# try block instead of being swallowed by its handler.
if write("abcdefghijklmnopqrstuvwxyz") != "abcdefghijklmnopqrstuvwxyz":
sys.exit("shift + write did not round-trip the alphabet")
release_keyboard_key("shift")
# this write will print one error -> keyboard write error can't find key : Ѓ and write remain string
try:
assert write("?123456789") == "123456789" # noqa: S101 # reason: integration test
written = write("?123456789")
except Exception as error:
print(repr(error), file=sys.stderr)
else:
if written != "123456789":
print(f"Unexpected write result: {written!r}", file=sys.stderr)
try:
write("!#@L@#{@#PL#{!@#L{!#{|##PO}!@#O@!O#P!)KI#O_!K")
except Exception as error:
Expand Down
Loading
Loading