Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/skills/component-development/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions backend/src/dsl/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ function isPlaceholderIssue(issue: ZodIssue, placeholderFields: Set<string>): 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[] };
Expand Down
2 changes: 1 addition & 1 deletion backend/src/workflows/dto/workflow-graph.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
31 changes: 30 additions & 1 deletion docs/development/component-development.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
port.map(port.text()) // Record<string, string>
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)
Expand Down
21 changes: 21 additions & 0 deletions docs/development/isolated-volumes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion frontend/src/components/workflow/RunWorkflowDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeInputType, 'string'>

const normalizeRuntimeInputType = (
Expand Down Expand Up @@ -289,6 +289,34 @@ export function RunWorkflowDialog({
</div>
)

case 'secret':
return (
<div className="space-y-2">
<Label htmlFor={input.id}>
{input.label}
{input.required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
id={input.id}
type="password"
placeholder="Enter secret value"
onChange={(e) => 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 && (
<p className="text-xs text-muted-foreground">{input.description}</p>
)}
{hasError && (
<p className="text-xs text-red-500">{errors[input.id]}</p>
)}
</div>
)

case 'text':
default:
return (
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/workflow/RuntimeInputsEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeInputType, 'string'>

const normalizeRuntimeInputType = (type: RuntimeInputType): NormalizedRuntimeInputType =>
Expand Down Expand Up @@ -188,6 +188,7 @@ export function RuntimeInputsEditor({ value, onChange }: RuntimeInputsEditorProp
<SelectItem value="number">Number</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="array">Array</SelectItem>
<SelectItem value="secret">Secret</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Expand Down
11 changes: 7 additions & 4 deletions packages/component-sdk/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,10 +417,13 @@ async function runDockerWithPty<I, O>(
`[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;
}

Expand Down
46 changes: 46 additions & 0 deletions worker/src/components/core/__tests__/entry-point.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EntryPointInput, EntryPointOutput>('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<EntryPointInput, EntryPointOutput>('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');
});
});
6 changes: 4 additions & 2 deletions worker/src/components/core/entry-point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
}));
Expand Down Expand Up @@ -125,7 +125,9 @@ const definition: ComponentDefinition<Input, Output> = {
});
}
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`);
Expand Down
22 changes: 13 additions & 9 deletions worker/src/components/security/supabase-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,10 @@ const definition: ComponentDefinition<Input, Output> = {
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,
Expand Down Expand Up @@ -283,15 +284,18 @@ const definition: ComponentDefinition<Input, Output> = {
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;
Expand Down
4 changes: 2 additions & 2 deletions worker/src/temporal/activities/run-component.activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -167,7 +167,7 @@ export async function runComponentActivity(
workflowId: input.workflowId,
organizationId: input.organizationId,
componentId: action.componentId,
inputs: maskSecretOutputs(component, params) as Record<string, unknown>,
inputs: maskSecretInputs(component, params) as Record<string, unknown>,
});

context.trace?.record({
Expand Down
58 changes: 58 additions & 0 deletions worker/src/temporal/utils/component-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,64 @@ import { componentRegistry } from '@shipsec/component-sdk';

type RegisteredComponent = NonNullable<ReturnType<typeof componentRegistry.get>>;

/**
* 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<string, unknown>) };
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.
*/
Expand Down
Loading