mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user