fix(railway): block source deploys without atomic repository binding

Remove the unsafe source-deployment mutation and deny retired catalog entries before upstream execution. Keep redeploy, restart and rollback available.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-09-16 12:14:58 -07:00
co-authored by Paperclip
parent 303340f190
commit d86530ab96
8 changed files with 99 additions and 36 deletions
+16 -5
View File
@@ -1,8 +1,9 @@
# Railway implementation verification
Updated: 2026-09-14. Implementation and local verification were performed on
2026-09-13. The change is being published for review on a dedicated branch.
Live qualification and a green full suite remain open.
Updated: 2026-09-16. Implementation and local verification were performed on
2026-09-13. The change is published for review on a dedicated branch.
Live qualification remains open. Full GitHub CI passed on commit `303340f19`
before the source-deployment security follow-up below.
## Thinking Path
@@ -41,7 +42,8 @@ per-action review and could inherit ambient credentials.
- Added Railway's generated definition, curated entry, official marks and provenance.
- Added fixed GraphQL operations for service/deployment status, bounded logs,
redeploy/restart/rollback, and deployment of an immutable Git revision.
and redeploy/restart/rollback. Source deployment is blocked pending atomic
provider repository/revision binding.
- Added grant-owned SSH key setup and container commands with host verification,
target checks, deadlines, output caps and cleanup.
- Blocked the hosted general agent and staged-change acceptance. Preserved normal
@@ -50,6 +52,13 @@ per-action review and could inherit ambient credentials.
## Verification
Security follow-up on 2026-09-16: removed the source-deployment schema and mutation.
The provider block applies to old active catalog entries and normalized aliases
before refresh; refresh marks them disabled. Regression tests cover direct-client
and gateway denial before any upstream request. A repository preflight is no
longer used as authorization for source deployment.
All 386 focused Railway, catalog and gateway tests passed for this follow-up.
After rebase onto master on 2026-09-14, 440 focused provider, connection, gateway,
catalog and container-panel tests passed. The AppDetail and AppsConnect suites
passed another 196 tests. The new Apps entries on master are preserved.
@@ -110,7 +119,9 @@ cleanup still require operator-assisted proof. See
are not local authorization allowlists.
- Container commands have broad internal authority; timeout cannot guarantee remote
child termination. Provider-side key removal is a separate operator action.
- Provider repository reconfiguration can race a source-deployment preflight.
- Source deployment is unavailable until the provider can atomically bind the
approved repository and commit. Existing deployments can still be redeployed,
restarted or rolled back.
- No schema migration is needed. Rollback can remove promotion and direct dispatch
while retaining connection data and the generic MCP path.
- The full test suite must be resolved before claiming release readiness.
+20 -9
View File
@@ -1,6 +1,6 @@
# Railway
Updated: 2026-09-14. Status: implementation review; live provider qualification outstanding.
Updated: 2026-09-16. Status: implementation review; live provider qualification outstanding.
Railway appears in Apps and uses Paperclip's shared remote-MCP OAuth connection,
vault, catalog, grants, policies, gateway, and audit trail. It is a resource
@@ -45,7 +45,7 @@ connecting. Loopback consent succeeded locally; HTTPS still needs live proof.
| `service-status`, `list-deployments`, `deployment-status` | Explicit project/environment/service IDs; deployment ID where applicable | Read |
| `read-logs` | Build/runtime; ≤500 lines; time bounds/filter; ≤64 KiB of log entries | Read; sensitive application data |
| `redeploy`, `restart`, `rollback` | Exact deployment membership checked before mutation | Destructive |
| `deploy-revision` | Existing service repository, exact 40-character Git SHA, explicit environment/service | Destructive |
| `deploy-revision` | Unavailable: the provider mutation cannot atomically bind the approved repository and commit; old catalog entries and calls are blocked | Destructive; unavailable |
| `run-command` | Exact running deployment/container instance, ≤60 seconds, ≤64 KiB combined output | Destructive; broad privileged access |
Direct tool names have the `paperclip-railway-` prefix. Railway may not shadow
@@ -53,8 +53,8 @@ this reserved namespace. These are fixed first-party gateway operations, not a
REST catalog entry or arbitrary GraphQL passthrough. GraphQL responses have a
1 MiB hard limit, redirects are refused, provider error bodies are not surfaced,
and deployment mutations are never automatically retried. After a timeout or
ambiguous error, inspect status before retrying. Redeploy/source deployment
return the provider's resulting deployment ID; restart/rollback use the provider's
ambiguous error, inspect status before retrying. Redeploy returns the provider's
resulting deployment ID; restart/rollback use the provider's
boolean result and exact target ID rather than inventing a new deployment ID.
Railway enforces the workspace/account permissions granted by consent. The
@@ -71,6 +71,15 @@ blocked by a narrow provider policy. Other providers and global defaults are
unchanged. New or changed Railway schemas are quarantined after initial discovery,
including reconnect flows that normally enable newly discovered actions.
Source deployment (`paperclip-railway-deploy-revision`) is also blocked. The
`serviceInstanceDeployV2` mutation accepts a commit SHA but cannot atomically
verify the approved repository. A separate repository check can race a provider
configuration change. Paperclip therefore offers 11 direct actions and no source
deployment action. Calls saved by an older server are denied before upstream
execution, including normalized aliases; refreshing actions marks their catalog
entries disabled. Source deployment requires an atomic provider binding before
it can be re-enabled. Redeploy uses an existing deployment's previous image.
## Container access
The connection's Permissions page includes Container access:
@@ -106,9 +115,7 @@ precedence and can stop the command earlier. Timeout/cancellation terminates the
local SSH connection. Remote child process
termination is not guaranteed. Persistent interactive sessions, file upload,
unrestricted Railway CLI use and arbitrary local workspace deployment are out
of scope. Source deployment uses only an already connected repository and immutable
commit. A concurrent provider repository reconfiguration can race the preflight;
Railway's API does not expose an atomic repository/revision binding for this call.
of scope. Source deployment is unavailable as described above.
Removing the container key deletes local private material and its grant binding
in one transaction, including for revoked grants or disconnected connections.
@@ -170,10 +177,12 @@ manifest contains runtime artwork paths; this record retains source provenance.
## Verification and release gate
`railway.test.ts` covers fixed API dispatch, bounds, errors, target checks and
credential redaction. `railway-ssh.test.ts` covers isolated SSH state, completion,
credential redaction, including source-deployment denial with no upstream request.
`railway-ssh.test.ts` covers isolated SSH state, completion,
output limits, timeout, cancellation and cleanup. `railway-connection.test.ts`
uses observed metadata with synthetic provider responses to exercise the shared
OAuth/catalog/grant/gateway lifecycle. The fixture explicitly does not claim an
OAuth/catalog/grant/gateway lifecycle and denies retired source-deployment catalog
entries before refresh. The fixture explicitly does not claim an
authenticated provider tool capture. Shared generic MCP suites cover callback
state/issuer binding, consent cancellation and credential handling.
@@ -185,6 +194,8 @@ refresh reported API access available, 44 active hosted actions, two disabled
actions, and 12 new direct actions quarantined for review. Direct project,
service and environment reads succeeded; the inspected project had no services,
so deployment status and logs could not be exercised. No provider mutation ran.
That preview included source deployment; the 2026-09-16 security fix removes it
and blocks existing entries, leaving 11 supported direct actions.
Full release acceptance remains outstanding. The operator must identify a
disposable service and deployment for the remaining checks.
+4 -2
View File
@@ -1,6 +1,7 @@
# Railway direct operations and container runtime review
Date: 2026-09-13. Scope: local preview authorized by the operator, without push.
Updated: 2026-09-16 for source-deployment security review.
The hosted connector alone cannot provide governed direct logs and shell. The
chosen runtime is a first-party fixed-operation bridge inside the existing MCP
@@ -12,8 +13,9 @@ The bridge verifies whether Railway accepts the actual grant credential for the
GraphQL API. Failed qualification disables direct capabilities. It never assumes
that OAuth tokens for one resource are valid for another or silently substitutes
ambient credentials. All mutations use fixed queries and check target membership.
Source deployment binds a configured repository and immutable commit, with the
provider reconfiguration race documented in RAILWAY.md.
Source deployment is blocked: a separate repository check can race the deployment
mutation. It requires an atomic provider repository/revision binding before it
can be enabled. Redeploy, restart and rollback target existing deployments.
Container commands run a fixed system OpenSSH client with isolated temporary
state, a dedicated vault-backed grant key, verified host trust and explicit
@@ -102,6 +102,36 @@ const initialTools = [
expect(JSON.stringify(await f.service.getConnection(f.connectionId))).not.toContain(token);
});
it("denies retired source-deployment entries before refresh and disables them on refresh", async () => {
const f = await fixture();
const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Railway operator", role: "engineer", adapterType: "process", adapterConfig: {} }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: agent.id, invocationSource: "on_demand", status: "running" }).returning();
const [profile] = await db.insert(toolProfiles).values({ companyId: f.company.id, name: "Railway tools", profileKey: randomUUID(), defaultAction: "allow" }).returning();
await db.insert(toolProfileBindings).values({ companyId: f.company.id, profileId: profile.id, targetType: "agent", targetId: agent.id });
const [existing] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, f.connectionId), eq(toolCatalogEntries.toolName, "paperclip-railway-restart")));
const names = ["paperclip-railway-deploy-revision", "paperclip_railway_deploy_revision", "paperclipRailwayDeployRevision"];
// Reproduce active catalog rows persisted by an older server, before refresh.
await db.insert(toolCatalogEntries).values(names.map((name) => ({ ...existing, id: randomUUID(), name, toolName: name, inputSchema: { type: "object" } })));
const gateway = createToolGatewayService(db, { remoteHttpRequest: f.request });
const session = await gateway.createSession({ companyId: f.company.id, agentId: agent.id, runId: run.id });
const listed = await gateway.listToolsForSession(session.token);
expect(listed.some((tool) => names.includes(tool.upstreamToolName ?? ""))).toBe(false);
const restart = listed.find((tool) => tool.upstreamToolName === "paperclip-railway-restart")!;
expect(restart).toBeTruthy();
f.request.mockClear();
const { deploymentId: _, ...ids } = target;
const parameters = { ...ids, repository: "example/app", commitSha: "a".repeat(40) };
await expect(gateway.executeTool({ sessionToken: session.token, tool: restart.name.replace("paperclip-railway-restart", names[0]), parameters, idempotencyKey: randomUUID() })).rejects.toMatchObject({ reasonCode: "tool_not_found" });
for (const toolName of names) {
await expect(gateway.executeTestCall({ companyId: f.company.id, connectionId: f.connectionId, agentId: agent.id, userId: actor.actorId, toolName, parameters })).rejects.toMatchObject({ reasonCode: "tool_not_found" });
}
expect(f.request).not.toHaveBeenCalled();
await f.service.refreshCatalog(f.connectionId, actor);
const retired = (await f.service.listCatalog(f.connectionId)).filter((entry) => names.includes(entry.toolName));
expect(retired).toHaveLength(names.length);
expect(retired.every((entry) => entry.status === "disabled")).toBe(true);
});
it("preserves the dedicated SSH grant key on reconnect and removes it while disconnected", async () => {
const f = await fixture();
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connectionId));
+10 -6
View File
@@ -53,7 +53,8 @@ describe("Railway governed operations", () => {
expect(isRailwayToolBlocked("accept_deploy")).toBe(true);
expect(railwayRisk("paperclip-railway-run-command")).toBe("destructive");
expect(railwayRisk("unfamiliar-tool")).toBe("write");
expect(RAILWAY_TOOLS).toHaveLength(12);
expect(RAILWAY_TOOLS).toHaveLength(11);
expect(RAILWAY_TOOLS.map((tool) => tool.name)).not.toContain("paperclip-railway-deploy-revision");
});
it("gives container commands time for target checks without exceeding the gateway limit", () => {
@@ -131,12 +132,15 @@ describe("Railway governed operations", () => {
expect(f.request).toHaveBeenCalledTimes(1);
});
it("binds source deployments to the current repository and immutable commit", async () => {
const f = fixture((q) => q === RAILWAY_QUERIES.deploy ? Response.json({ data: { serviceInstanceDeployV2: instanceId } }) : undefined);
it.each(["paperclip-railway-deploy-revision", "paperclip_railway_deploy_revision", "paperclipRailwayDeployRevision"])("blocks %s before any repository preflight or deployment mutation", async (name) => {
// Even a matching repository in the preflight can change before mutation.
// Without an atomic provider binding, no upstream request is safe to send.
const f = fixture();
const { deploymentId: _, ...ids } = target;
await expect(f.client.call("paperclip-railway-deploy-revision", { ...ids, repository: "other/repo", commitSha: "a".repeat(40) })).rejects.toMatchObject({ code: "railway_repository_mismatch" });
await expect(f.client.call("paperclip-railway-deploy-revision", { ...ids, repository: "example/app", commitSha: "main" })).rejects.toMatchObject({ code: "railway_invalid_arguments" });
await expect(f.client.call("paperclip-railway-deploy-revision", { ...ids, repository: "example/app", commitSha: "a".repeat(40) })).resolves.toMatchObject({ deploymentId: instanceId });
await expect(f.client.call(name, { ...ids, repository: "example/app", commitSha: "a".repeat(40) })).rejects.toMatchObject({ code: "railway_action_blocked", status: 403 });
expect(f.request).not.toHaveBeenCalled();
expect(isRailwayToolBlocked(name)).toBe(true);
expect(railwayRisk(name)).toBe("destructive");
});
it("allows only an instance in the exact running deployment to reach SSH", async () => {
+9 -13
View File
@@ -11,7 +11,13 @@ export function railwayCommandBudgetMs(parameters: unknown): number {
const requested = typeof seconds === "number" && Number.isFinite(seconds) ? seconds : 30;
return Math.min(60_000, (Math.max(1, requested) + 10) * 1000);
}
export const RAILWAY_BLOCKED_TOOLS = new Set(["railway-agent", "accept-deploy"]);
export const RAILWAY_BLOCKED_TOOLS = new Set([
"railway-agent", "accept-deploy",
// A separate repository preflight cannot bind serviceInstanceDeployV2 to the
// approved repository. Keep old catalog entries/calls blocked until Railway
// provides an atomic repository + revision mutation.
`${RAILWAY_TOOL_PREFIX}deploy-revision`,
]);
export function normalizeRailwayToolName(name: string): string {
return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[:._-]+/g, "-");
}
@@ -51,7 +57,6 @@ const schema = {
redeploy: z.object(deploymentTarget).strict(),
restart: z.object(deploymentTarget).strict(),
rollback: z.object(deploymentTarget).strict(),
"deploy-revision": z.object({ ...target, repository: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), commitSha: z.string().regex(/^[a-f0-9]{40}$/i) }).strict(),
"run-command": z.object({ ...deploymentTarget, deploymentInstanceId: id, command: z.string().min(1).max(8192), timeoutSeconds: z.number().int().min(1).max(60).default(30) }).strict(),
};
type Operation = keyof typeof schema;
@@ -66,7 +71,6 @@ const titles: Record<Operation, string> = {
redeploy: "Redeploy a deployment",
restart: "Restart a deployment",
rollback: "Roll back to a deployment",
"deploy-revision": "Deploy a Git revision",
"run-command": "Run a container command",
};
const descriptions: Record<Operation, string> = {
@@ -80,7 +84,6 @@ const descriptions: Record<Operation, string> = {
redeploy: "Redeploy an exact deployment using its previous image. This changes a running service.",
restart: "Restart an exact deployment without rebuilding. This interrupts a running service.",
rollback: "Roll back to an exact eligible deployment. This changes a running service.",
"deploy-revision": "Deploy an immutable Git commit from the service's already-connected GitHub repository. Specify the exact repository, revision and target.",
"run-command": "Run a bounded noninteractive shell command in an exact deployed container using this connection's configured SSH key. Broad privileged access: commands can read secrets and mutate application data. Requires Container access setup. Timeout closes SSH; remote child termination is not guaranteed.",
};
const reads = new Set<Operation>(["list-projects", "list-services", "list-environments", "service-status", "list-deployments", "deployment-status", "read-logs"]);
@@ -95,6 +98,7 @@ export const RAILWAY_TOOLS = Object.entries(schema).map(([operation, validator])
export function railwayRisk(name: string): "read" | "write" | "destructive" {
name = normalizeRailwayToolName(name);
if (isRailwayToolBlocked(name)) return "destructive";
const operation = name.slice(RAILWAY_TOOL_PREFIX.length) as Operation;
if (name.startsWith(RAILWAY_TOOL_PREFIX) && operation in schema) return reads.has(operation) ? "read" : "destructive";
if (["whoami", "list-projects", "list-services", "list-feature-flags", "get-feature-flag"].includes(name)) return "read";
@@ -133,7 +137,6 @@ export const RAILWAY_QUERIES = {
redeploy: `mutation PaperclipRailwayRedeploy($deploymentId:String!) { deploymentRedeploy(id:$deploymentId,usePreviousImageTag:true) { id status } }`,
restart: `mutation PaperclipRailwayRestart($deploymentId:String!) { deploymentRestart(id:$deploymentId) }`,
rollback: `mutation PaperclipRailwayRollback($deploymentId:String!) { deploymentRollback(id:$deploymentId) }`,
deploy: `mutation PaperclipRailwayDeployRevision($environmentId:String!,$serviceId:String!,$commitSha:String!) { serviceInstanceDeployV2(environmentId:$environmentId,serviceId:$serviceId,commitSha:$commitSha) }`,
};
function record(value: unknown): Record<string, any> {
@@ -246,6 +249,7 @@ export function createRailwayClient(options: RailwayClientOptions) {
await query(RAILWAY_QUERIES.projects, { workspaceId, first: 1 });
},
async call(name: string, parameters: unknown): Promise<unknown> {
if (isRailwayToolBlocked(name)) throw new RailwayError("railway_action_blocked", "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", 403);
const operation = name.slice(RAILWAY_TOOL_PREFIX.length) as Operation;
if (!name.startsWith(RAILWAY_TOOL_PREFIX) || !Object.hasOwn(schema, operation)) throw new RailwayError("railway_unknown_tool", "Unknown Railway operation.", 400);
const parsed = schema[operation].safeParse(parameters);
@@ -295,14 +299,6 @@ export function createRailwayClient(options: RailwayClientOptions) {
if (record(result).deploymentRollback !== true) throw new RailwayError("railway_operation_unconfirmed", "Railway did not confirm the rollback. Inspect deployment status before retrying.");
result = { ...record(result), targetDeploymentId: args.deploymentId };
break;
case "deploy-revision":
if (record(instance.source).repo !== args.repository) throw new RailwayError("railway_repository_mismatch", "The repository does not match the service's configured source.", 409);
{
const deploymentId = (await query(RAILWAY_QUERIES.deploy, { environmentId: args.environmentId, serviceId: args.serviceId, commitSha: args.commitSha })).serviceInstanceDeployV2;
if (!id.safeParse(deploymentId).success) throw new RailwayError("railway_invalid_response", "Railway did not confirm a resulting deployment ID. Inspect deployment status before retrying.");
result = { deploymentId, commitSha: args.commitSha };
}
break;
case "run-command":
if (!Array.isArray(deployment?.instances) || !deployment.instances.some((entry: { id: string }) => entry.id === args.deploymentInstanceId) || deployment.status !== "SUCCESS") throw new RailwayError("railway_target_mismatch", "The container instance is not part of the selected running deployment.", 403);
if (!options.runCommand) throw new RailwayError("railway_ssh_setup_required", "Configure Container access on this Railway connection before running commands.", 422);
+9
View File
@@ -7763,6 +7763,15 @@ export function toolAccessService(
const existingByName = new Map(
existingRows.map((entry) => [entry.toolName, entry]),
);
// Retired native actions are absent from discovery, but old catalog rows
// still need to show as disabled. Gateway denial also applies before refresh.
const blockedRailwayEntryIds = isRailwayEndpoint(connection.config.url)
? existingRows.filter((entry) => isRailwayToolBlocked(entry.toolName)).map((entry) => entry.id)
: [];
if (blockedRailwayEntryIds.length > 0) {
await db.update(toolCatalogEntries).set({ status: "disabled", updatedAt: refreshedAt })
.where(and(eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, blockedRailwayEntryIds)));
}
const updatedEntries: ToolCatalogEntry[] = [];
let quarantinedCount = 0;
const sourceTemplateKey =
+1 -1
View File
@@ -4676,7 +4676,7 @@ export function createToolGatewayService(
);
}
if (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(entry.toolName)) {
throw new ToolGatewayHttpError(403, "This Railway action cannot be individually governed. Use the dedicated deployment actions.", "railway_action_blocked");
throw new ToolGatewayHttpError(403, "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", "railway_action_blocked");
}
return { entry, connection };
}