Skip to content

Sandbox Profile

sandbox_profile

Sandbox profiles: what a target repo needs inside the agent sandbox.

A sandbox profile describes the toolchains, egress presets, in-sandbox setup and validation commands, declared skips, environment variables, resources and download exclusions for one target repo. The same shape is used in two places:

  • Central profiles come from reviewed CI configuration (for example autofix.json) and may set everything, including raw egress endpoints, resources and overlay.
  • Overlay profiles come from the target repo (the sandbox: section of .agentic-ci/config.yml on its base branch). A repo file must not be able to break the run or widen what central config allows, so fields an overlay may not set are dropped with a warning instead of failing the parse.

:func:parse_profile validates raw YAML/JSON data into an immutable :class:SandboxProfile, :func:merge_profiles combines a central profile with an overlay, and :func:profile_to_dict / :func:profile_hash serialize one. SkillConfig.sandbox_profile carries the result to the backend.

In this release only resources takes effect (OpenShellBackend sizes the sandbox with it). The other fields are validated and carried but not yet acted on.

Error and warning messages name the field path (for example validate[2].kind) and the rule broken. They never include env values or run strings, which can hold anything.

KNOWN_TOOLCHAINS = frozenset({'buf', 'go', 'golangci-lint', 'helm', 'kustomize', 'node', 'pnpm', 'protoc', 'python', 'shfmt', 'yq'}) module-attribute

Toolchain names a profile may request. A later release replaces this with a catalog.

KNOWN_EGRESS_PRESETS = frozenset({'github-release-assets', 'goproxy', 'npm', 'pypi'}) module-attribute

Egress preset names a profile may request. A later release attaches endpoint lists.

DEFAULT_OVERLAY_ALLOWED_PRESETS = frozenset({'goproxy', 'npm', 'pypi'}) module-attribute

Egress presets a repo overlay may add unless the caller allows others.

FrozenMap(items=())

Bases: Mapping[str, str]

Immutable, hashable str -> str mapping with keys kept in sorted order.

Source code in src/agentic_ci/sandbox_profile.py
def __init__(self, items: Mapping[str, str] | Iterable[tuple[str, str]] = ()) -> None:
    pairs = items.items() if isinstance(items, Mapping) else items
    self._data: dict[str, str] = dict(sorted(pairs))

SetupStep(name, run, timeout=DEFAULT_STEP_TIMEOUT) dataclass

A command run inside the sandbox before the agent starts.

ValidateStep(name, kind, run, timeout=DEFAULT_STEP_TIMEOUT) dataclass

A validation command; kind is one of lint, build, test, generated.

Skip(match, reason) dataclass

A check the sandbox can never run, recorded instead of attempted.

Resources(memory=None, cpu=None, gpu=None) dataclass

Sandbox size. None leaves that limit to OpenShell's default.

SandboxProfile(toolchains=FrozenMap(), egress=(), raw_egress=(), setup=(), validate=(), skips=(), env=FrozenMap(), resources=None, discard_before_download=(), overlay='merge') dataclass

A validated, immutable sandbox profile.

Build one with :func:parse_profile. Mappings passed to the constructor are frozen and sequences turned into tuples, so a profile cannot change after it is created. A Resources with no limit set becomes None, so equivalent profiles compare and hash the same.

ParsedProfile(profile, warnings=()) dataclass

Result of :func:parse_profile: the profile and any non-fatal warnings.

MergedProfile(profile, warnings=()) dataclass

Result of :func:merge_profiles: the effective profile and merge warnings.

SandboxProfileError(path, rule)

Bases: ValueError

A sandbox profile broke a rule. path names the field, e.g. validate[2].kind.

Source code in src/agentic_ci/sandbox_profile.py
def __init__(self, path: str, rule: str) -> None:
    super().__init__(f"{path}: {rule}")
    self.path = path
    self.rule = rule

parse_profile(data, *, source)

Validate raw profile data (the YAML/JSON shape) into a :class:SandboxProfile.

Parameters:

Name Type Description Default
data object

The profile mapping, as loaded from JSON or YAML. A missing (None) field means its default.

required
source Source

"central" for reviewed CI configuration, "overlay" for a target repo's own file.

required

Returns:

Name Type Description
ParsedProfile

class:ParsedProfile with the profile and a tuple of warnings (at

most ParsedProfile

data:MAX_WARNINGS, then one summary line). A central profile

ParsedProfile

yields no warnings: anything wrong raises.

Raises:

Type Description
SandboxProfileError

The data breaks a rule. For an overlay, unknown keys, unknown toolchains and egress presets, rejected env names, duplicate step names, raw egress endpoints, resources and overlay are dropped with a warning instead.

Rules worth knowing when writing a profile:

  • Quote toolchain versions (go: "1.20"). Integers are accepted and converted, but an unquoted decimal is rejected because YAML has already turned 1.20 into 1.2.
  • env values must be strings. Integers are converted with str(); booleans, decimals, null, lists and mappings are rejected.
  • egress mixes preset names and raw host:port:access endpoints; an entry containing : is a raw endpoint.
  • Each list or mapping holds at most :data:MAX_ENTRIES entries.
Source code in src/agentic_ci/sandbox_profile.py
def parse_profile(data: object, *, source: Source) -> ParsedProfile:
    """Validate raw profile data (the YAML/JSON shape) into a :class:`SandboxProfile`.

    Args:
        data: The profile mapping, as loaded from JSON or YAML. A missing
            (``None``) field means its default.
        source: ``"central"`` for reviewed CI configuration, ``"overlay"``
            for a target repo's own file.

    Returns:
        :class:`ParsedProfile` with the profile and a tuple of warnings (at
        most :data:`MAX_WARNINGS`, then one summary line). A central profile
        yields no warnings: anything wrong raises.

    Raises:
        SandboxProfileError: The data breaks a rule. For an overlay, unknown
            keys, unknown toolchains and egress presets, rejected ``env``
            names, duplicate step names, raw egress endpoints, ``resources``
            and ``overlay`` are dropped with a warning instead.

    Rules worth knowing when writing a profile:

    - Quote toolchain versions (``go: "1.20"``). Integers are accepted and
      converted, but an unquoted decimal is rejected because YAML has
      already turned ``1.20`` into ``1.2``.
    - ``env`` values must be strings. Integers are converted with ``str()``;
      booleans, decimals, null, lists and mappings are rejected.
    - ``egress`` mixes preset names and raw ``host:port:access`` endpoints;
      an entry containing ``:`` is a raw endpoint.
    - Each list or mapping holds at most :data:`MAX_ENTRIES` entries.
    """
    parser = _Parser(source)
    profile = parser.parse(data)
    return ParsedProfile(profile=profile, warnings=_cap_warnings(parser.warnings))

merge_profiles(central, overlay, *, overlay_allowed_presets=DEFAULT_OVERLAY_ALLOWED_PRESETS)

Combine a central profile with a repo overlay.

Central scalars and map keys win (toolchains, env, resources, overlay). Lists (setup, validate, skips, egress, discard_before_download) are concatenated central first and de-duplicated; an overlay step whose name the central list already uses is dropped. An overlay skip is dropped when the central profile has a skip with the same match, or when its match and the name or run of a central validate step contain one another (ignoring case), so a repo cannot skip validation that central configuration requires. Overlay egress presets outside overlay_allowed_presets, overlay raw endpoints and overlay resources are dropped. A central overlay: ignore drops the overlay entirely.

The overlay is held to the same envelope even when it was built directly instead of by :func:parse_profile: unknown toolchains, bad toolchain versions, rejected env names and discard_before_download paths that are absolute, contain .. or name the workdir or .git are dropped with a warning.

An overlay without a central profile is merged into a default envelope: overlay: merge, no resources and no raw egress.

Returns:

Type Description
MergedProfile | None

None when both are None, otherwise :class:MergedProfile

MergedProfile | None

with the effective profile and any warnings (at most

MergedProfile | None

data:MAX_WARNINGS, then one summary line). Never raises for an

MergedProfile | None

overlay that parse_profile(..., source="overlay") accepted.

Source code in src/agentic_ci/sandbox_profile.py
def merge_profiles(
    central: SandboxProfile | None,
    overlay: SandboxProfile | None,
    *,
    overlay_allowed_presets: AbstractSet[str] = DEFAULT_OVERLAY_ALLOWED_PRESETS,
) -> MergedProfile | None:
    """Combine a central profile with a repo overlay.

    Central scalars and map keys win (``toolchains``, ``env``, ``resources``,
    ``overlay``). Lists (``setup``, ``validate``, ``skips``, ``egress``,
    ``discard_before_download``) are concatenated central first and
    de-duplicated; an overlay step whose name the central list already uses
    is dropped. An overlay skip is dropped when the central profile has a
    skip with the same ``match``, or when its ``match`` and the name or ``run``
    of a central ``validate`` step contain one another (ignoring case), so a
    repo cannot skip validation that central configuration requires. Overlay egress presets outside
    *overlay_allowed_presets*, overlay raw endpoints and overlay
    ``resources`` are dropped. A central ``overlay: ignore`` drops the
    overlay entirely.

    The overlay is held to the same envelope even when it was built directly
    instead of by :func:`parse_profile`: unknown toolchains, bad toolchain
    versions, rejected ``env`` names and ``discard_before_download`` paths that
    are absolute, contain ``..`` or name the workdir or ``.git`` are dropped
    with a warning.

    An overlay without a central profile is merged into a default envelope:
    ``overlay: merge``, no resources and no raw egress.

    Returns:
        ``None`` when both are ``None``, otherwise :class:`MergedProfile`
        with the effective profile and any warnings (at most
        :data:`MAX_WARNINGS`, then one summary line). Never raises for an
        overlay that ``parse_profile(..., source="overlay")`` accepted.
    """
    if central is None and overlay is None:
        return None
    base = central if central is not None else SandboxProfile()
    if overlay is None:
        return MergedProfile(profile=base)
    warnings: list[str] = []
    if base.overlay == "ignore":
        warnings.append("overlay: the central profile sets overlay: ignore; repo overlay dropped")
        return MergedProfile(profile=base, warnings=tuple(warnings))

    presets = []
    for preset in overlay.egress:
        if preset not in overlay_allowed_presets:
            warnings.append(
                f"egress: preset {_show(preset)} is not allowed in a repo overlay; dropped"
            )
            continue
        presets.append(preset)
    if overlay.raw_egress:
        warnings.append(
            "egress: raw endpoints are accepted only from the central profile; "
            f"{len(overlay.raw_egress)} dropped"
        )
    if overlay.resources is not None:
        warnings.append("resources: set only by the central profile; overlay value ignored")

    toolchains = _overlay_toolchains(overlay, warnings)
    env = _overlay_env(overlay, warnings)
    discard = _overlay_discard(overlay, warnings)
    profile = SandboxProfile(
        toolchains=_merge_map(base.toolchains, toolchains, "toolchains", warnings),
        egress=_merge_unique(base.egress, presets),
        raw_egress=base.raw_egress,
        setup=_merge_steps(base.setup, overlay.setup, "setup", warnings),
        validate=_merge_steps(base.validate, overlay.validate, "validate", warnings),
        skips=_merge_skips(base, overlay.skips, warnings),
        env=_merge_map(base.env, env, "env", warnings),
        resources=base.resources,
        discard_before_download=_merge_unique(base.discard_before_download, discard),
        overlay=base.overlay,
    )
    return MergedProfile(profile=profile, warnings=_cap_warnings(warnings))

profile_to_dict(profile)

Serialize profile to the shape :func:parse_profile accepts.

Preset names come before raw endpoints in egress. resources is omitted when unset, as are its unset members.

Source code in src/agentic_ci/sandbox_profile.py
def profile_to_dict(profile: SandboxProfile) -> dict[str, Any]:
    """Serialize *profile* to the shape :func:`parse_profile` accepts.

    Preset names come before raw endpoints in ``egress``. ``resources`` is
    omitted when unset, as are its unset members.
    """
    data: dict[str, Any] = {
        "toolchains": dict(profile.toolchains),
        "egress": [*profile.egress, *profile.raw_egress],
        "setup": [{"name": s.name, "run": s.run, "timeout": s.timeout} for s in profile.setup],
        "validate": [
            {"name": v.name, "kind": v.kind, "run": v.run, "timeout": v.timeout}
            for v in profile.validate
        ],
        "skips": [{"match": s.match, "reason": s.reason} for s in profile.skips],
        "env": dict(profile.env),
        "discard_before_download": list(profile.discard_before_download),
        "overlay": profile.overlay,
    }
    if profile.resources is not None:
        resources = {
            key: getattr(profile.resources, key)
            for key in ("memory", "cpu", "gpu")
            if getattr(profile.resources, key) is not None
        }
        data["resources"] = resources
    return data

profile_hash(profile)

Return the sha256 hex digest of profile's canonical JSON form.

Source code in src/agentic_ci/sandbox_profile.py
def profile_hash(profile: SandboxProfile) -> str:
    """Return the sha256 hex digest of *profile*'s canonical JSON form."""
    canonical = json.dumps(profile_to_dict(profile), sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()