Files
PaperClipAI/server/src/services/execution-allowlist.ts
T
Devin Foley 0044fa8904 Let tenants edit env vars on managed sandbox environments; add managed-sandbox-only mode (#11200)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Environments give each agent run an execution target: the local
host, SSH, or a sandbox provider
> - A managed deployment can provision one platform-managed sandbox
environment through the `PAPERCLIP_MANAGED_CONFIG` `environments`
section
> - That row is fully locked today. A tenant cannot add environment
variables for their agents. There is also no way to hide local execution
— run selection falls back to the local row
> - A platform that manages the sandbox for its tenants needs both: the
tenant adds env vars (and nothing else), and local execution is neither
visible nor reachable
> - This pull request opens exactly one tenant edit (env vars) on the
managed sandbox row, and adds an `enableManagedSandboxOnly` mode that
hides local and makes run selection fail closed
> - The benefit is a complete managed-sandbox experience with no change
for self-hosted instances

## Linked Issues or Issue Description

**Subsystem affected**

Environments (managed sandbox provisioning, environment routes, run
environment selection) and the environments UI.

**Problem or motivation**

Platform-provisioned sandbox environments
(`metadata.managedByPaperclip`) reject every write on cloud-managed
instances. Agents often need environment variables inside their sandbox.
The tenant has no way to set them on the managed row. Separately, an
operator cannot remove local execution: the environment list always
shows the local row, and run selection falls back to it when no default
is set.

**Proposed solution**

Allow an envVars-only PATCH on the managed sandbox row, and echo those
env vars back for editing. Add a managed-tier feature
(`enableManagedSandboxOnly`) that hides the local environment from all
read surfaces and redirects local-landing run selection to the managed
sandbox environment, failing closed when it is unavailable.

**Alternatives considered**

UI-only hiding of the local row. This was rejected: it does not stop a
run from resolving to local, so it is presentation without enforcement.
Full unlock of the managed row was also rejected: name, driver, and
config stay platform-owned so boot reconciliation cannot fight tenant
edits.

## What Changed

- `server/src/routes/environments.ts`: the platform-provisioned write
floor admits an envVars-only PATCH on the generalized managed sandbox
row (sandbox driver, `managedByPaperclip`, not legacy kubernetes-marker
rows). Name, driver, config, status, metadata, and DELETE stay rejected.
The read floor stops blanking env vars on that row; credential-shaped
config keys stay redacted for every actor. Legacy kubernetes-marker rows
keep the full floor.
- Same file: under `enableManagedSandboxOnly`, the environments list and
the by-id read omit the local row for every actor, including instance
admins.
- `server/src/services/execution-workspace-policy.ts`:
`resolveExecutionWorkspaceEnvironmentId` gains the managed-sandbox-only
inputs. A selection that lands on the local environment is redirected to
the managed sandbox environment. With no active managed row it throws
`ManagedSandboxUnavailableError` — never local. Non-local selections
(ssh, user-created sandboxes) are untouched.
- `server/src/services/heartbeat.ts`: the run path reads the flag, looks
up the managed row (`findManagedSandboxEnvironment`, new read-only
finder in `environments.ts`), and passes both to the resolver. Mirrors
the forced-kubernetes precedent, which keeps precedence when both
regimes are on.
- `server/src/services/managed-environments.ts`: after a successful
reconcile, the instance default environment moves to the managed sandbox
row when the current default is unset, local, or dangling. A
tenant-chosen custom environment is never overridden.
- `packages/shared`: new `enableManagedSandboxOnly` key (schema default
false, catalog tier `managed`, cloudDefault false, selfHostedDefault
false) and the matching interface field.
- UI: managed rows show a "Managed by Paperclip" lock badge; editing one
opens a dedicated env-vars-only editor that sends the one PATCH shape
the server admits (the old full form failed with a 403 on save). New
`ui/src/lib/managed-sandbox-environment.ts` mirrors the local filter for
cached lists (applied in the project picker; the agent picker already
excluded local). The experimental settings page gains the toggle at its
alphabetical card position. `environmentsApi.update` now declares the
`envVars` field it already sent.
- Tests: environment route floor coverage (envVars-only accepted, mixed
bodies rejected, legacy rows still blanked and locked, local hidden and
404 under the flag, self-hosted unchanged), an embedded-postgres service
test pinning that boot reconciliation never touches tenant env vars,
resolver redirect/fail-closed cases, managed-environments
default-stamping cases, and UI lib/settings tests.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/environment-routes.test.ts
src/__tests__/environment-service.test.ts
src/__tests__/execution-workspace-policy.test.ts
src/services/managed-environments.test.ts` — all pass.
- `pnpm --filter @paperclipai/shared exec vitest run` — 425 pass
(catalog/schema default parity is pinned by an existing test).
- `pnpm --filter @paperclipai/ui exec tsc --noEmit` and the affected UI
suites (CompanyEnvironments, InstanceExperimentalSettings incl.
card-order test, new lib test) — all pass.
- Full workspace `pnpm test`: 3,414 passed. 17 files report failures on
this machine; the identical 17 fail on a clean `origin/master` worktree
in the same environment (git-worktree/skills/embedded-postgres
environment dependencies and plugin-SDK zero-test collections). One
additional file (`issue-monitor-scheduler.test.ts`) failed one
timing-sensitive test in one of two full-suite runs and passes 7/7 in
isolation on this branch — a flake in a domain this diff does not touch.
The branch introduces no new failures.
- Self-hosted zero-delta: every new behavior is gated on the
cloud-managed instance check or the new flag, which defaults to false in
schema and catalog; pinned by the "does not floor platform-marked rows
on self-hosted instances" and flag-off tests.

## Risks

- Behavior is opt-in twice over: the write-floor exception applies only
to rows the managed-config provisioner stamps, and the hiding/forcing
applies only when `enableManagedSandboxOnly` is on (default false
everywhere). Self-hosted instances see no change.
- The env-vars echo is scoped to the generalized managed sandbox row;
legacy kubernetes-marker rows keep the blanket floor because
pre-generalization builds may have written platform values there.
- Fail-closed run selection means a managed instance with the flag on
and an archived managed row (provider plugin down) refuses runs with a
precise error instead of running locally. That is the intended posture.

## Model Used

Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use
via Claude Code CLI.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-11 11:40:57 -07:00

135 lines
5.4 KiB
TypeScript

/**
* Pure execution-allowlist guard.
*
* Decides whether a candidate execution environment is permitted to run an
* agent, given the instance-level execution policy. This is security-critical:
* on a shared cloud instance we FORCE all untrusted tenant agents onto the
* Kubernetes sandbox-provider and REFUSE local/in-process execution so that a
* tenant agent can never run inside the server process or on an unsandboxed
* local/ssh adapter.
*
* The merged tree's environment model represents the Kubernetes sandbox as a
* core `driver: "sandbox"` environment whose `config.provider` is the plugin's
* `driverKey` ("kubernetes", `kind: "sandbox_provider"`). The local default is
* `driver: "local"`. This module knows nothing about the DB or heartbeat — it
* just maps (driver, provider, policy) -> allow/deny so it is trivially
* unit-testable.
*/
/** Provider key (== plugin driverKey) of the first-party Kubernetes sandbox provider. */
export const KUBERNETES_PROVIDER_KEY = "kubernetes" as const;
/**
* Instance execution policy as read from instance settings.
*
* - `executionMode` `"any"` / absent: unrestricted driver selection (the
* default, preserves single-tenant / local-trusted behavior).
* - `executionMode` `"kubernetes"`: force the Kubernetes sandbox provider;
* deny local, ssh, and any non-kubernetes sandbox provider.
* - `managedSandboxOnly`: deny the `local` driver so untrusted agent code
* never executes on the tenant's Paperclip-operated container. Unlike
* the kubernetes mode this does NOT pin a single provider — the tenant
* may still run on the platform-managed sandbox or on their own
* sandbox/ssh infrastructure; only in-process/local execution is
* refused. This is the run-time backstop behind the local-hiding UI and
* the resolver's local→managed redirect: even if selection or a
* tenant-set env var reached a `local` environment, the run fails here
* rather than executing on Paperclip compute.
*/
export interface ExecutionPolicy {
executionMode?: "kubernetes" | "any";
managedSandboxOnly?: boolean;
}
/**
* The minimal shape of the selected/candidate environment the guard needs.
* `driver` is the core `EnvironmentDriver`; `provider` is the sandbox provider
* key (== plugin driverKey) for `driver: "sandbox"` environments, else null.
*/
export interface ExecutionEnvironmentCandidate {
driver: string;
provider: string | null | undefined;
}
export type ExecutionAllowlistDecision =
| { allowed: true }
| {
allowed: false;
reason: string;
deniedDriver: string;
deniedProvider: string | null;
};
/** True when the policy forces all execution onto the Kubernetes sandbox. */
export function isExecutionForcedToKubernetes(policy: ExecutionPolicy | null | undefined): boolean {
return policy?.executionMode === "kubernetes";
}
/** True when the policy refuses local (in-process/on-container) execution. */
export function isLocalExecutionDenied(policy: ExecutionPolicy | null | undefined): boolean {
return policy?.managedSandboxOnly === true;
}
/**
* True iff the candidate environment is the Kubernetes sandbox provider, i.e. a
* core `sandbox` driver whose provider key is "kubernetes".
*/
export function isKubernetesSandboxEnvironment(
candidate: ExecutionEnvironmentCandidate,
): boolean {
return candidate.driver === "sandbox" && candidate.provider === KUBERNETES_PROVIDER_KEY;
}
/**
* Decide whether the candidate environment may run under the given policy.
*
* When `executionMode === "kubernetes"`, ONLY a `sandbox_provider` driver with
* provider/driverKey "kubernetes" is allowed; a `local` driver (or any non-k8s
* sandbox provider, or ssh, or plugin) is DENIED. Otherwise everything is
* allowed.
*/
export function evaluateExecutionAllowlist(
policy: ExecutionPolicy | null | undefined,
candidate: ExecutionEnvironmentCandidate,
): ExecutionAllowlistDecision {
const provider = candidate.provider ?? null;
if (isExecutionForcedToKubernetes(policy)) {
if (isKubernetesSandboxEnvironment(candidate)) {
return { allowed: true };
}
const target =
candidate.driver === "sandbox"
? `sandbox provider "${provider ?? "(none)"}"`
: `"${candidate.driver}" driver`;
return {
allowed: false,
reason:
`Instance execution policy requires the Kubernetes sandbox provider ` +
`(executionMode=kubernetes), but the resolved environment uses the ${target}. ` +
`Untrusted execution on a non-Kubernetes environment is refused.`,
deniedDriver: candidate.driver,
deniedProvider: provider,
};
}
// Managed-sandbox-only refuses the `local` driver specifically: untrusted
// agent code must never run in-process / on the tenant's Paperclip
// container. Other drivers (the platform-managed sandbox, or a tenant's
// own sandbox/ssh infrastructure) stay allowed — this mode hides local
// and forces the managed default, it does not pin a single provider.
if (isLocalExecutionDenied(policy) && candidate.driver === "local") {
return {
allowed: false,
reason:
`Instance execution policy forbids local execution (managed sandbox only), ` +
`but the resolved environment uses the "local" driver. Untrusted execution on ` +
`the tenant container is refused; agents run in the platform-managed sandbox.`,
deniedDriver: candidate.driver,
deniedProvider: provider,
};
}
return { allowed: true };
}