From 882316e3df43a253305fba7f8c1eaa41dc6af4c7 Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Wed, 14 Jan 2026 22:21:18 +0530 Subject: [PATCH 1/2] feat(entry-point): add secret runtime input type and fix nonroot container volumes - Add 'secret' type to entry point runtime inputs schema (worker, backend, frontend) - Render secret inputs as password fields in RunWorkflowDialog - Add secret option to RuntimeInputsEditor dropdown - Fix IsolatedContainerVolume permissions for nonroot containers (distroless) - Add setVolumePermissions() to chmod 777 after writing files - Add tests for secret runtime inputs and port resolution - Update docs for secret input type and nonroot container support Signed-off-by: Aseem Shrey --- .claude/skills/component-development/SKILL.md | 12 +++++ .../src/workflows/dto/workflow-graph.dto.ts | 2 +- docs/development/component-development.mdx | 31 ++++++++++++- docs/development/isolated-volumes.mdx | 21 +++++++++ .../components/workflow/RunWorkflowDialog.tsx | 30 +++++++++++- .../workflow/RuntimeInputsEditor.tsx | 3 +- .../core/__tests__/entry-point.test.ts | 46 +++++++++++++++++++ worker/src/components/core/entry-point.ts | 2 +- worker/src/utils/isolated-volume.ts | 45 ++++++++++++++++++ 9 files changed, 187 insertions(+), 5 deletions(-) diff --git a/.claude/skills/component-development/SKILL.md b/.claude/skills/component-development/SKILL.md index e939fb5a..de5bd1c4 100644 --- a/.claude/skills/component-development/SKILL.md +++ b/.claude/skills/component-development/SKILL.md @@ -109,12 +109,24 @@ const volume = new IsolatedContainerVolume(tenantId, context.runId); try { await volume.initialize({ 'input.txt': data }); // volumes: [volume.getVolumeConfig('/path', true)] + // Note: Permissions are auto-set for nonroot containers } finally { await volume.cleanup(); } ``` → See: `docs/development/isolated-volumes.mdx` +### Entry Point Runtime Inputs +```typescript +// Supported types: text, number, file, json, array, secret +const runtimeInputs = [ + { id: 'apiKey', label: 'API Key', type: 'secret', required: true }, + { id: 'targets', label: 'Targets', type: 'array', required: true }, +]; +// Secret type renders as password field in UI +``` +→ See: `docs/development/component-development.mdx#entry-point-runtime-input-types` + ### Dynamic Ports ```typescript resolvePorts(params) { diff --git a/backend/src/workflows/dto/workflow-graph.dto.ts b/backend/src/workflows/dto/workflow-graph.dto.ts index c3693901..1908b7e7 100644 --- a/backend/src/workflows/dto/workflow-graph.dto.ts +++ b/backend/src/workflows/dto/workflow-graph.dto.ts @@ -242,7 +242,7 @@ export class WorkflowVersionResponseDto extends createZodDto(WorkflowVersionResp export const RuntimeInputSchema = z.object({ id: z.string(), label: z.string(), - type: z.enum(['text', 'string', 'number', 'json', 'array', 'file', 'boolean']), + type: z.enum(['text', 'string', 'number', 'json', 'array', 'file', 'boolean', 'secret']), required: z.boolean().default(true), description: z.string().optional(), defaultValue: z.unknown().optional(), diff --git a/docs/development/component-development.mdx b/docs/development/component-development.mdx index f6c952fb..6efe60cd 100644 --- a/docs/development/component-development.mdx +++ b/docs/development/component-development.mdx @@ -213,14 +213,43 @@ import { port } from '@shipsec/component-sdk'; port.text() // String port.number() // Number port.boolean() // Boolean -port.secret() // Secret value +port.secret() // Secret value (masked in UI) port.json() // JSON object port.any() // Any type +port.file() // File reference port.list(port.text()) // Array port.map(port.text()) // Record port.credential('github') // OAuth credential contract ``` +### Entry Point Runtime Input Types + +The Entry Point component supports dynamic runtime inputs that users provide when triggering workflows: + +| Type | Description | UI Rendering | +|------|-------------|--------------| +| `text` | Text input | Multi-line textarea | +| `number` | Numeric input | Number field | +| `file` | File upload | File picker | +| `json` | JSON data | JSON textarea | +| `array` | List of values | Comma-separated or JSON array | +| `secret` | Sensitive data | Password field (masked) | + +**Example: Secret runtime input** + +```typescript +// Entry point configuration +const runtimeInputs = [ + { id: 'apiKey', label: 'API Key', type: 'secret', required: true }, + { id: 'target', label: 'Target URL', type: 'text', required: true }, +]; +``` + +When a workflow with secret inputs is triggered: +1. The UI shows a password field for the secret +2. The value flows through as a `port.secret()` output +3. Downstream components receive the secret string value + --- ## Dynamic Ports (resolvePorts) diff --git a/docs/development/isolated-volumes.mdx b/docs/development/isolated-volumes.mdx index e67d3683..40000599 100644 --- a/docs/development/isolated-volumes.mdx +++ b/docs/development/isolated-volumes.mdx @@ -264,6 +264,27 @@ volume.getVolumeConfig('/inputs', true) // ✅ read-only volume.getVolumeConfig('/outputs', false) // ⚠️ read-write ``` +### Nonroot Container Support + +Volumes automatically support containers running as nonroot users (e.g., distroless images with uid 65532). + +After files are written to the volume, permissions are set to `777` to allow any container user to read/write: + +```typescript +// This happens automatically in volume.initialize() +// chmod -R 777 /data +``` + +**Why this is needed:** +- Files are written to volumes using Alpine containers (running as root) +- Distroless nonroot images run as uid 65532 +- Without permission changes, nonroot containers can't write output files + +**This is safe because:** +- Each volume is isolated per tenant + run +- Volumes are cleaned up after execution +- No cross-tenant access is possible + ### Path Validation Filenames are automatically validated: diff --git a/frontend/src/components/workflow/RunWorkflowDialog.tsx b/frontend/src/components/workflow/RunWorkflowDialog.tsx index 04283a34..224bce7a 100644 --- a/frontend/src/components/workflow/RunWorkflowDialog.tsx +++ b/frontend/src/components/workflow/RunWorkflowDialog.tsx @@ -14,7 +14,7 @@ import { Textarea } from '@/components/ui/textarea' import { Play, Loader2 } from 'lucide-react' import { api } from '@/services/api' -type RuntimeInputType = 'file' | 'text' | 'number' | 'json' | 'array' | 'string' +type RuntimeInputType = 'file' | 'text' | 'number' | 'json' | 'array' | 'string' | 'secret' type NormalizedRuntimeInputType = Exclude const normalizeRuntimeInputType = ( @@ -289,6 +289,34 @@ export function RunWorkflowDialog({ ) + case 'secret': + return ( +
+ + handleInputChange(input.id, e.target.value, inputType)} + className={hasError ? 'border-red-500' : ''} + defaultValue={ + inputs[input.id] !== undefined && inputs[input.id] !== null + ? String(inputs[input.id]) + : '' + } + /> + {input.description && ( +

{input.description}

+ )} + {hasError && ( +

{errors[input.id]}

+ )} +
+ ) + case 'text': default: return ( diff --git a/frontend/src/components/workflow/RuntimeInputsEditor.tsx b/frontend/src/components/workflow/RuntimeInputsEditor.tsx index 4f29e708..40a12785 100644 --- a/frontend/src/components/workflow/RuntimeInputsEditor.tsx +++ b/frontend/src/components/workflow/RuntimeInputsEditor.tsx @@ -12,7 +12,7 @@ import { SelectValue, } from '@/components/ui/select' -type RuntimeInputType = 'file' | 'text' | 'number' | 'json' | 'array' | 'string' +type RuntimeInputType = 'file' | 'text' | 'number' | 'json' | 'array' | 'string' | 'secret' type NormalizedRuntimeInputType = Exclude const normalizeRuntimeInputType = (type: RuntimeInputType): NormalizedRuntimeInputType => @@ -188,6 +188,7 @@ export function RuntimeInputsEditor({ value, onChange }: RuntimeInputsEditorProp Number JSON Array + Secret

diff --git a/worker/src/components/core/__tests__/entry-point.test.ts b/worker/src/components/core/__tests__/entry-point.test.ts index 8894bfba..58650f80 100644 --- a/worker/src/components/core/__tests__/entry-point.test.ts +++ b/worker/src/components/core/__tests__/entry-point.test.ts @@ -107,4 +107,50 @@ describe('entry-point component', () => { "Required runtime input 'User' (user) was not provided", ); }); + + it('should handle secret runtime inputs', async () => { + const component = componentRegistry.get('core.workflow.entrypoint'); + if (!component) throw new Error('Component not registered'); + + const context = createExecutionContext({ + runId: 'test-run', + componentRef: 'trigger-test', + }); + + const params = component.inputSchema.parse({ + runtimeInputs: [ + { id: 'apiKey', label: 'API Key', type: 'secret', required: true }, + { id: 'token', label: 'Token', type: 'secret', required: false }, + ], + __runtimeData: { + apiKey: 'super-secret-key', + token: 'optional-token', + }, + }); + + const result = await component.execute(params, context); + + expect(result).toEqual({ + apiKey: 'super-secret-key', + token: 'optional-token', + }); + }); + + it('should resolve secret ports correctly', () => { + const component = componentRegistry.get('core.workflow.entrypoint'); + if (!component) throw new Error('Component not registered'); + + const params = { + runtimeInputs: [ + { id: 'apiKey', label: 'API Key', type: 'secret', required: true }, + ], + }; + + const ports = component.resolvePorts?.(params as any); + expect(ports).toBeDefined(); + expect(ports!.outputs).toHaveLength(1); + expect(ports!.outputs[0].id).toBe('apiKey'); + expect(ports!.outputs[0].dataType.kind).toBe('primitive'); + expect((ports!.outputs[0].dataType as any).name).toBe('secret'); + }); }); diff --git a/worker/src/components/core/entry-point.ts b/worker/src/components/core/entry-point.ts index 662c15fd..7d1ebeab 100644 --- a/worker/src/components/core/entry-point.ts +++ b/worker/src/components/core/entry-point.ts @@ -16,7 +16,7 @@ const runtimeInputDefinitionSchema = z.preprocess((value) => { }, z.object({ id: z.string().describe('Unique identifier for this input'), label: z.string().describe('Display label for the input field'), - type: z.enum(['file', 'text', 'number', 'json', 'array']).describe('Type of input data'), + type: z.enum(['file', 'text', 'number', 'json', 'array', 'secret']).describe('Type of input data'), required: z.boolean().default(true).describe('Whether this input is required'), description: z.string().optional().describe('Help text for the input'), })); diff --git a/worker/src/utils/isolated-volume.ts b/worker/src/utils/isolated-volume.ts index 4b3ea784..a78a92e0 100644 --- a/worker/src/utils/isolated-volume.ts +++ b/worker/src/utils/isolated-volume.ts @@ -163,6 +163,51 @@ export class IsolatedContainerVolume { // Use docker run with stdin to write the file await this.writeFileToVolume(filename, contentString); } + + // Make the volume directory writable by all users (including nonroot containers) + // This is safe because volumes are isolated per-run + await this.setVolumePermissions(); + } + + /** + * Sets permissions on the volume directory to allow nonroot containers to write. + * Uses chmod 777 on /data to support distroless nonroot images (uid 65532). + */ + private async setVolumePermissions(): Promise { + if (!this.volumeName) { + throw new ConfigurationError('Volume not initialized', { + details: { tenantId: this.tenantId, runId: this.runId }, + }); + } + + return new Promise((resolve, reject) => { + const proc = spawn('docker', [ + 'run', + '--rm', + '-v', `${this.volumeName}:/data`, + '--entrypoint', 'sh', + 'alpine:latest', + '-c', 'chmod -R 777 /data', + ]); + + let stderr = ''; + + proc.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + proc.on('error', (error) => { + reject(new Error(`Failed to set volume permissions: ${error.message}`)); + }); + + proc.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Failed to set volume permissions: exit code ${code}, stderr: ${stderr}`)); + } else { + resolve(); + } + }); + }); } /** From 82540d0266364d2d7dca4807ad2a250574963c7c Mon Sep 17 00:00:00 2001 From: Aseem Shrey Date: Thu, 15 Jan 2026 00:04:45 +0530 Subject: [PATCH 2/2] fix(worker): PTY fallback and supabase-scanner distroless compatibility - Fix PTY fallback to restore -i flag when falling back to standard IO - Update supabase-scanner for distroless image (no shell wrapper needed) - Add maskSecretInputs helper for masking secret inputs in activity logs - Handle custom Zod validations in DSL validator for placeholder fields - Mask secret runtime inputs in entry-point component logs Signed-off-by: Aseem Shrey --- backend/src/dsl/validator.ts | 4 ++ packages/component-sdk/src/runner.ts | 11 ++-- worker/src/components/core/entry-point.ts | 4 +- .../components/security/supabase-scanner.ts | 22 ++++--- .../activities/run-component.activity.ts | 4 +- worker/src/temporal/utils/component-output.ts | 58 +++++++++++++++++++ 6 files changed, 87 insertions(+), 16 deletions(-) diff --git a/backend/src/dsl/validator.ts b/backend/src/dsl/validator.ts index 9185a81f..2f5ccd34 100644 --- a/backend/src/dsl/validator.ts +++ b/backend/src/dsl/validator.ts @@ -139,6 +139,10 @@ function isPlaceholderIssue(issue: ZodIssue, placeholderFields: Set): bo return true; case 'too_big': return true; + case 'custom': + // Custom validations (from .refine()) fail on placeholders but will pass at runtime + // when the actual value comes from the connected edge + return true; case 'invalid_union': if ('unionErrors' in issue) { const unionIssue = issue as ZodIssue & { unionErrors: ZodError[] }; diff --git a/packages/component-sdk/src/runner.ts b/packages/component-sdk/src/runner.ts index c6e9ec05..6a98c38e 100644 --- a/packages/component-sdk/src/runner.ts +++ b/packages/component-sdk/src/runner.ts @@ -417,10 +417,13 @@ async function runDockerWithPty( `[Docker][PTY] Failed to spawn PTY: ${error instanceof Error ? error.message : String(error)}. Diagnostic: ${JSON.stringify(diag)}`, ); context.logger.warn('[Docker][PTY] Falling back to standard IO due to PTY spawn failure'); - - // Remove -t flag before falling back (stdin is not a TTY) - const argsWithoutTty = dockerArgs.filter((arg) => arg !== '-t'); - resolve(runDockerWithStandardIO(argsWithoutTty, params, context, timeoutSeconds)); + + // Remove -t flag and restore -i flag for standard IO (it was removed for PTY mode) + const argsForStandardIO = dockerArgs.filter((arg) => arg !== '-t'); + if (!argsForStandardIO.includes('-i')) { + argsForStandardIO.splice(2, 0, '-i'); + } + resolve(runDockerWithStandardIO(argsForStandardIO, params, context, timeoutSeconds)); return; } diff --git a/worker/src/components/core/entry-point.ts b/worker/src/components/core/entry-point.ts index 7d1ebeab..536913bf 100644 --- a/worker/src/components/core/entry-point.ts +++ b/worker/src/components/core/entry-point.ts @@ -125,7 +125,9 @@ const definition: ComponentDefinition = { }); } outputs[inputDef.id] = value; - context.logger.info(`[EntryPoint] Output '${inputDef.id}' = ${typeof value === 'object' ? JSON.stringify(value) : value}`); + // Mask secret values in logs + const logValue = inputDef.type === 'secret' ? '***' : (typeof value === 'object' ? JSON.stringify(value) : value); + context.logger.info(`[EntryPoint] Output '${inputDef.id}' = ${logValue}`); } context.emitProgress(`Collected ${Object.keys(outputs).length} runtime inputs`); diff --git a/worker/src/components/security/supabase-scanner.ts b/worker/src/components/security/supabase-scanner.ts index 5a63c8e4..b498e46e 100644 --- a/worker/src/components/security/supabase-scanner.ts +++ b/worker/src/components/security/supabase-scanner.ts @@ -129,9 +129,10 @@ const definition: ComponentDefinition = { kind: 'docker', image: 'ghcr.io/shipsecai/supabase-scanner:latest', network: 'bridge', - // Entry-point from the image handles a single CLI argument: the config path - // We set the argument in execute() via command: ['/configs/scanner_config.yaml'] - command: ['/configs/scanner_config.yaml'], + // Distroless image (no shell) - use image's ENTRYPOINT directly + // ENTRYPOINT: ["/usr/bin/python3", "/app/supabase_scanner.py"] + // Config path passed as command argument + command: [], timeoutSeconds: 180, }, inputSchema, @@ -283,15 +284,18 @@ const definition: ComponentDefinition = { let volumeInitialized = false; // Build runner with isolated volume mounts - const baseRunner = definition.runner; + // Distroless image uses ENTRYPOINT directly, config path passed as command arg + const baseRunner = definition.runner as DockerRunnerConfig; const runner: DockerRunnerConfig = { - ...(baseRunner.kind === 'docker' - ? baseRunner - : { kind: 'docker', image: 'ghcr.io/shipsecai/supabase-scanner:latest', command: [containerConfigPath] }), - env: { ...(baseRunner.kind === 'docker' ? baseRunner.env ?? {} : {}) }, + kind: 'docker', + image: baseRunner.image, + network: baseRunner.network, + timeoutSeconds: baseRunner.timeoutSeconds, + env: { ...(baseRunner.env ?? {}) }, + // Pass config path as command argument to image's ENTRYPOINT command: [containerConfigPath], volumes: [], - } as DockerRunnerConfig; + }; let report: unknown = {}; let score: number | null = null; diff --git a/worker/src/temporal/activities/run-component.activity.ts b/worker/src/temporal/activities/run-component.activity.ts index 1434e482..cb7ddc1e 100644 --- a/worker/src/temporal/activities/run-component.activity.ts +++ b/worker/src/temporal/activities/run-component.activity.ts @@ -17,7 +17,7 @@ import { type AgentTracePublisher, } from '@shipsec/component-sdk'; -import { maskSecretOutputs, createLightweightSummary } from '../utils/component-output'; +import { maskSecretInputs, maskSecretOutputs, createLightweightSummary } from '../utils/component-output'; import { RedisTerminalStreamAdapter } from '../../adapters'; import type { RunComponentActivityInput, @@ -167,7 +167,7 @@ export async function runComponentActivity( workflowId: input.workflowId, organizationId: input.organizationId, componentId: action.componentId, - inputs: maskSecretOutputs(component, params) as Record, + inputs: maskSecretInputs(component, params) as Record, }); context.trace?.record({ diff --git a/worker/src/temporal/utils/component-output.ts b/worker/src/temporal/utils/component-output.ts index 7cb17206..b07a1dd6 100644 --- a/worker/src/temporal/utils/component-output.ts +++ b/worker/src/temporal/utils/component-output.ts @@ -2,6 +2,64 @@ import { componentRegistry } from '@shipsec/component-sdk'; type RegisteredComponent = NonNullable>; +/** + * Masks values based on a list of secret port definitions. + */ +function maskSecretPorts( + secretPorts: Array<{ id: string }>, + data: unknown +): unknown { + if (secretPorts.length === 0) { + return data; + } + + if (secretPorts.some((port) => port.id === '__self__')) { + return '***'; + } + + if (data && typeof data === 'object' && !Array.isArray(data)) { + const clone = { ...(data as Record) }; + for (const port of secretPorts) { + if (Object.prototype.hasOwnProperty.call(clone, port.id)) { + clone[port.id] = '***'; + } + } + return clone; + } + + return '***'; +} + +/** + * Identifies secret ports from a list of port definitions. + */ +function getSecretPorts( + ports: Array<{ id: string; dataType?: { kind: string; name?: string; credential?: boolean } }> | undefined +): Array<{ id: string }> { + return ( + ports?.filter((port) => { + if (!port.dataType) { + return false; + } + if (port.dataType.kind === 'primitive') { + return port.dataType.name === 'secret'; + } + if (port.dataType.kind === 'contract') { + return Boolean(port.dataType.credential); + } + return false; + }) ?? [] + ); +} + +/** + * Masks inputs containing sensitive information (secrets) based on component metadata. + */ +export function maskSecretInputs(component: RegisteredComponent, input: unknown): unknown { + const secretPorts = getSecretPorts(component.metadata?.inputs); + return maskSecretPorts(secretPorts, input); +} + /** * Masks outputs containing sensitive information (secrets) based on component metadata. */