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.

Every field takes effect in OpenShellBackend: it sizes the sandbox with resources, opens the egress presets and raw endpoints (agentic_ci.backends.openshell.policy holds the preset endpoints), provisions the toolchains from :mod:agentic_ci.toolchains, runs the setup steps in the sandbox before the agent and the validate commands after it, records the skips, exports env and removes the discard_before_download paths (agentic_ci.backends.openshell.steps).

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(TOOLCHAIN_CATALOG) module-attribute

Toolchain names a profile may request: the entries of agentic_ci.toolchains.CATALOG.

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

Egress preset names a profile may request.

The endpoints live in agentic_ci.backends.openshell.policy.EGRESS_PRESETS, whose names a test keeps equal to this set; this module stays backend-neutral.

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

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

CREDENTIAL_ENDPOINT_OPTIONS = frozenset({'allow-uninspected-credentials', 'request-body-credential-rewrite', 'websocket-credential-rewrite'}) module-attribute

Raw endpoint options that let a rule carry or rewrite provider credentials.

A raw endpoint may also carry allowed-ip=IPV4[/BITS] options. The OpenShell backend never opens an endpoint with a credential option to the setup shim.

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

is_raw_endpoint(entry)

Return whether entry is a raw endpoint a central profile's egress accepts.

The same host:port:access[:protocol[:enforcement[:options]]] grammar :func:parse_profile applies, for code that builds policy from endpoint strings and must not keep a second copy of the rules.

Source code in src/agentic_ci/sandbox_profile.py
def is_raw_endpoint(entry: object) -> bool:
    """Return whether *entry* is a raw endpoint a central profile's ``egress`` accepts.

    The same ``host:port:access[:protocol[:enforcement[:options]]]`` grammar
    :func:`parse_profile` applies, for code that builds policy from endpoint
    strings and must not keep a second copy of the rules.
    """
    if not isinstance(entry, str):
        return False
    try:
        _check_raw_endpoint(entry, "egress")
    except SandboxProfileError:
        return False
    return True

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()