@kb-labs/workflow-steps 2.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/dist/approval.d.ts +44 -0
- package/dist/approval.js +11 -0
- package/dist/approval.js.map +1 -0
- package/dist/gate.d.ts +97 -0
- package/dist/gate.js +95 -0
- package/dist/gate.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +323 -0
- package/dist/index.js.map +1 -0
- package/dist/shell.d.ts +74 -0
- package/dist/shell.js +226 -0
- package/dist/shell.js.map +1 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @kb-labs/workflow-builtins
|
|
2
|
+
|
|
3
|
+
Built-in workflow handlers (shell, etc.)
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @kb-labs/workflow-builtins
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { ... } from '@kb-labs/workflow-builtins';
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## API
|
|
18
|
+
|
|
19
|
+
See TypeScript types for detailed API documentation.
|
|
20
|
+
|
|
21
|
+
## License
|
|
22
|
+
|
|
23
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @kb-labs/workflow-steps/approval
|
|
3
|
+
* Types and handler for builtin:approval step
|
|
4
|
+
*
|
|
5
|
+
* Approval steps pause the pipeline and wait for human decision.
|
|
6
|
+
* The worker handles polling; resolveApproval() on the engine resumes execution.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Input for builtin:approval step (spec.with)
|
|
10
|
+
*/
|
|
11
|
+
interface ApprovalInput {
|
|
12
|
+
/** Display title for the approval request */
|
|
13
|
+
title: string;
|
|
14
|
+
/** Contextual data shown to the approver (already interpolated) */
|
|
15
|
+
context?: Record<string, unknown>;
|
|
16
|
+
/** Optional instructions for the approver */
|
|
17
|
+
instructions?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Output produced by a resolved approval step
|
|
21
|
+
*/
|
|
22
|
+
interface ApprovalOutput {
|
|
23
|
+
/** Whether the approval was granted */
|
|
24
|
+
approved: boolean;
|
|
25
|
+
/** Action taken: "approve" or "reject" */
|
|
26
|
+
action: 'approve' | 'reject';
|
|
27
|
+
/** Optional comment from the approver */
|
|
28
|
+
comment?: string;
|
|
29
|
+
/** Additional data provided by the approver */
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* ApprovalHandler — signals the worker to pause and wait for human approval.
|
|
34
|
+
* The worker owns the polling loop; this handler only marks the intent.
|
|
35
|
+
*/
|
|
36
|
+
declare class ApprovalHandler {
|
|
37
|
+
static readonly uses = "builtin:approval";
|
|
38
|
+
handle(_input: ApprovalInput): {
|
|
39
|
+
status: 'waiting';
|
|
40
|
+
reason: 'approval';
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { ApprovalHandler, type ApprovalInput, type ApprovalOutput };
|
package/dist/approval.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// src/approval.ts
|
|
2
|
+
var ApprovalHandler = class {
|
|
3
|
+
static uses = "builtin:approval";
|
|
4
|
+
handle(_input) {
|
|
5
|
+
return { status: "waiting", reason: "approval" };
|
|
6
|
+
}
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export { ApprovalHandler };
|
|
10
|
+
//# sourceMappingURL=approval.js.map
|
|
11
|
+
//# sourceMappingURL=approval.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/approval.ts"],"names":[],"mappings":";AA2CO,IAAM,kBAAN,MAAsB;AAAA,EAC3B,OAAgB,IAAA,GAAO,kBAAA;AAAA,EAEvB,OAAO,MAAA,EAAkE;AACvE,IAAA,OAAO,EAAE,MAAA,EAAQ,SAAA,EAAW,MAAA,EAAQ,UAAA,EAAW;AAAA,EACjD;AACF","file":"approval.js","sourcesContent":["/**\n * @module @kb-labs/workflow-steps/approval\n * Types and handler for builtin:approval step\n *\n * Approval steps pause the pipeline and wait for human decision.\n * The worker handles polling; resolveApproval() on the engine resumes execution.\n */\n\n/**\n * Input for builtin:approval step (spec.with)\n */\nexport interface ApprovalInput {\n /** Display title for the approval request */\n title: string;\n\n /** Contextual data shown to the approver (already interpolated) */\n context?: Record<string, unknown>;\n\n /** Optional instructions for the approver */\n instructions?: string;\n}\n\n/**\n * Output produced by a resolved approval step\n */\nexport interface ApprovalOutput {\n /** Whether the approval was granted */\n approved: boolean;\n\n /** Action taken: \"approve\" or \"reject\" */\n action: 'approve' | 'reject';\n\n /** Optional comment from the approver */\n comment?: string;\n\n /** Additional data provided by the approver */\n [key: string]: unknown;\n}\n\n/**\n * ApprovalHandler — signals the worker to pause and wait for human approval.\n * The worker owns the polling loop; this handler only marks the intent.\n */\nexport class ApprovalHandler {\n static readonly uses = 'builtin:approval';\n\n handle(_input: ApprovalInput): { status: 'waiting'; reason: 'approval' } {\n return { status: 'waiting', reason: 'approval' };\n }\n}\n"]}
|
package/dist/gate.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { ExpressionContext } from '@kb-labs/workflow-contracts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module @kb-labs/workflow-steps/gate
|
|
5
|
+
* Types and handler for builtin:gate step
|
|
6
|
+
*
|
|
7
|
+
* Gate steps act as automatic routers — they read a decision value
|
|
8
|
+
* from previous step outputs and route the pipeline accordingly:
|
|
9
|
+
* - continue: proceed to next step
|
|
10
|
+
* - fail: fail the pipeline
|
|
11
|
+
* - restart: reset steps back to target and re-schedule with context
|
|
12
|
+
* - skip: mark intermediate steps as skipped and jump forward to a target step
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Route action for a gate decision
|
|
17
|
+
*/
|
|
18
|
+
type GateRouteAction = 'continue' | 'fail' | {
|
|
19
|
+
/** Step ID to restart from (go backward) */
|
|
20
|
+
restartFrom: string;
|
|
21
|
+
/** Additional context to pass (merged into trigger.payload) */
|
|
22
|
+
context?: Record<string, unknown>;
|
|
23
|
+
} | {
|
|
24
|
+
/** Step ID to skip forward to — all steps between gate and target are marked skipped */
|
|
25
|
+
skipTo: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Input for builtin:gate step (spec.with)
|
|
29
|
+
*/
|
|
30
|
+
interface GateInput {
|
|
31
|
+
/** Expression path to the decision value (e.g. "steps.review.outputs.passed") */
|
|
32
|
+
decision: string;
|
|
33
|
+
/** Route map: decision value → action */
|
|
34
|
+
routes: Record<string, GateRouteAction>;
|
|
35
|
+
/** Default action if decision value doesn't match any route */
|
|
36
|
+
default?: 'continue' | 'fail';
|
|
37
|
+
/** Maximum number of restart iterations before failing (default: 3) */
|
|
38
|
+
maxIterations?: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Output produced by a resolved gate step
|
|
42
|
+
*/
|
|
43
|
+
interface GateOutput {
|
|
44
|
+
/** The decision value that was evaluated */
|
|
45
|
+
decisionValue: unknown;
|
|
46
|
+
/** The action that was taken */
|
|
47
|
+
action: 'continue' | 'fail' | 'restart' | 'skip';
|
|
48
|
+
/** Step ID that was restarted from (if restart) */
|
|
49
|
+
restartFrom?: string;
|
|
50
|
+
/** Step ID that was skipped to (if skip) */
|
|
51
|
+
skipTo?: string;
|
|
52
|
+
/** Current iteration count */
|
|
53
|
+
iteration: number;
|
|
54
|
+
/** Set to true when the gate exhausted its maxIterations budget */
|
|
55
|
+
maxIterationsReached?: boolean;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Pure decision result from GateHandler.
|
|
60
|
+
* Worker applies the state mutations based on the action.
|
|
61
|
+
*/
|
|
62
|
+
type GateDecision = {
|
|
63
|
+
action: 'continue';
|
|
64
|
+
outputs: GateOutput;
|
|
65
|
+
} | {
|
|
66
|
+
action: 'fail';
|
|
67
|
+
error: Error;
|
|
68
|
+
outputs: GateOutput;
|
|
69
|
+
} | {
|
|
70
|
+
action: 'restart';
|
|
71
|
+
restartFrom: string;
|
|
72
|
+
context?: Record<string, unknown>;
|
|
73
|
+
outputs: GateOutput;
|
|
74
|
+
nextIteration: number;
|
|
75
|
+
} | {
|
|
76
|
+
action: 'skip';
|
|
77
|
+
skipTo: string;
|
|
78
|
+
outputs: GateOutput;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* GateHandler — pure decision function.
|
|
82
|
+
* No I/O, no state mutations. Worker owns the side effects.
|
|
83
|
+
*/
|
|
84
|
+
declare class GateHandler {
|
|
85
|
+
static readonly uses = "builtin:gate";
|
|
86
|
+
/**
|
|
87
|
+
* @param validRestartTargets Identifiers the worker can reset on restart —
|
|
88
|
+
* step ids/spec.ids within the gate's own job AND other job names (for
|
|
89
|
+
* cross-job restart). When provided and `restartFrom` matches none of them,
|
|
90
|
+
* the gate FAILS instead of attempting a restart the worker cannot apply —
|
|
91
|
+
* which would otherwise silently degrade into "complete the gate and
|
|
92
|
+
* proceed" (a false green).
|
|
93
|
+
*/
|
|
94
|
+
handle(input: GateInput, exprCtx: ExpressionContext, currentIteration: number, validRestartTargets?: string[]): GateDecision;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export { type GateDecision, GateHandler, type GateInput, type GateOutput, type GateRouteAction };
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { resolveValue } from '@kb-labs/workflow-contracts';
|
|
2
|
+
|
|
3
|
+
// src/gate.ts
|
|
4
|
+
var GateHandler = class {
|
|
5
|
+
static uses = "builtin:gate";
|
|
6
|
+
/**
|
|
7
|
+
* @param validRestartTargets Identifiers the worker can reset on restart —
|
|
8
|
+
* step ids/spec.ids within the gate's own job AND other job names (for
|
|
9
|
+
* cross-job restart). When provided and `restartFrom` matches none of them,
|
|
10
|
+
* the gate FAILS instead of attempting a restart the worker cannot apply —
|
|
11
|
+
* which would otherwise silently degrade into "complete the gate and
|
|
12
|
+
* proceed" (a false green).
|
|
13
|
+
*/
|
|
14
|
+
handle(input, exprCtx, currentIteration, validRestartTargets) {
|
|
15
|
+
const maxIterations = input.maxIterations ?? 3;
|
|
16
|
+
const decisionValue = resolveValue(input.decision, exprCtx);
|
|
17
|
+
const decisionKey = String(decisionValue);
|
|
18
|
+
const route = input.routes[decisionKey] ?? input.routes[decisionValue];
|
|
19
|
+
const action = route ?? input.default ?? "fail";
|
|
20
|
+
if (action === "continue") {
|
|
21
|
+
return {
|
|
22
|
+
action: "continue",
|
|
23
|
+
outputs: { decisionValue, action: "continue", iteration: currentIteration }
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (action === "fail") {
|
|
27
|
+
const failReason = route?.message ?? `No matching route for value "${decisionKey}"`;
|
|
28
|
+
return {
|
|
29
|
+
action: "fail",
|
|
30
|
+
error: new Error(`Gate failed: ${failReason}`),
|
|
31
|
+
outputs: { decisionValue, action: "fail", iteration: currentIteration }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
if (typeof action === "object" && "skipTo" in action) {
|
|
35
|
+
if (validRestartTargets && !validRestartTargets.includes(action.skipTo)) {
|
|
36
|
+
return {
|
|
37
|
+
action: "fail",
|
|
38
|
+
error: new Error(
|
|
39
|
+
`Gate skipTo "${action.skipTo}" matches no step in this job \u2014 cannot skip.`
|
|
40
|
+
),
|
|
41
|
+
outputs: { decisionValue, action: "fail", iteration: currentIteration }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
action: "skip",
|
|
46
|
+
skipTo: action.skipTo,
|
|
47
|
+
outputs: {
|
|
48
|
+
decisionValue,
|
|
49
|
+
action: "skip",
|
|
50
|
+
skipTo: action.skipTo,
|
|
51
|
+
iteration: currentIteration
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const restartAction = action;
|
|
56
|
+
if (validRestartTargets && !validRestartTargets.includes(restartAction.restartFrom)) {
|
|
57
|
+
return {
|
|
58
|
+
action: "fail",
|
|
59
|
+
error: new Error(
|
|
60
|
+
`Gate restartFrom "${restartAction.restartFrom}" matches no step in this job or any job in the run \u2014 cannot restart. Failing instead of silently passing.`
|
|
61
|
+
),
|
|
62
|
+
outputs: { decisionValue, action: "fail", iteration: currentIteration }
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const nextIteration = currentIteration + 1;
|
|
66
|
+
if (nextIteration >= maxIterations) {
|
|
67
|
+
return {
|
|
68
|
+
action: "fail",
|
|
69
|
+
error: new Error(`Gate max iterations reached (${maxIterations}) for step`),
|
|
70
|
+
outputs: {
|
|
71
|
+
decisionValue,
|
|
72
|
+
action: "fail",
|
|
73
|
+
iteration: currentIteration,
|
|
74
|
+
maxIterationsReached: true
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
action: "restart",
|
|
80
|
+
restartFrom: restartAction.restartFrom,
|
|
81
|
+
context: restartAction.context,
|
|
82
|
+
outputs: {
|
|
83
|
+
decisionValue,
|
|
84
|
+
action: "restart",
|
|
85
|
+
restartFrom: restartAction.restartFrom,
|
|
86
|
+
iteration: nextIteration
|
|
87
|
+
},
|
|
88
|
+
nextIteration
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export { GateHandler };
|
|
94
|
+
//# sourceMappingURL=gate.js.map
|
|
95
|
+
//# sourceMappingURL=gate.js.map
|
package/dist/gate.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/gate.ts"],"names":[],"mappings":"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","file":"gate.js","sourcesContent":["/**\n * @module @kb-labs/workflow-steps/gate\n * Types and handler for builtin:gate step\n *\n * Gate steps act as automatic routers — they read a decision value\n * from previous step outputs and route the pipeline accordingly:\n * - continue: proceed to next step\n * - fail: fail the pipeline\n * - restart: reset steps back to target and re-schedule with context\n * - skip: mark intermediate steps as skipped and jump forward to a target step\n */\n\nimport { resolveValue, type ExpressionContext } from '@kb-labs/workflow-contracts';\n\n/**\n * Route action for a gate decision\n */\nexport type GateRouteAction =\n | 'continue'\n | 'fail'\n | {\n /** Step ID to restart from (go backward) */\n restartFrom: string;\n /** Additional context to pass (merged into trigger.payload) */\n context?: Record<string, unknown>;\n }\n | {\n /** Step ID to skip forward to — all steps between gate and target are marked skipped */\n skipTo: string;\n };\n\n/**\n * Input for builtin:gate step (spec.with)\n */\nexport interface GateInput {\n /** Expression path to the decision value (e.g. \"steps.review.outputs.passed\") */\n decision: string;\n\n /** Route map: decision value → action */\n routes: Record<string, GateRouteAction>;\n\n /** Default action if decision value doesn't match any route */\n default?: 'continue' | 'fail';\n\n /** Maximum number of restart iterations before failing (default: 3) */\n maxIterations?: number;\n}\n\n/**\n * Output produced by a resolved gate step\n */\nexport interface GateOutput {\n /** The decision value that was evaluated */\n decisionValue: unknown;\n\n /** The action that was taken */\n action: 'continue' | 'fail' | 'restart' | 'skip';\n\n /** Step ID that was restarted from (if restart) */\n restartFrom?: string;\n\n /** Step ID that was skipped to (if skip) */\n skipTo?: string;\n\n /** Current iteration count */\n iteration: number;\n\n /** Set to true when the gate exhausted its maxIterations budget */\n maxIterationsReached?: boolean;\n\n [key: string]: unknown;\n}\n\n/**\n * Pure decision result from GateHandler.\n * Worker applies the state mutations based on the action.\n */\nexport type GateDecision =\n | { action: 'continue'; outputs: GateOutput }\n | { action: 'fail'; error: Error; outputs: GateOutput }\n | {\n action: 'restart';\n restartFrom: string;\n context?: Record<string, unknown>;\n outputs: GateOutput;\n nextIteration: number;\n }\n | {\n action: 'skip';\n skipTo: string;\n outputs: GateOutput;\n };\n\n/**\n * GateHandler — pure decision function.\n * No I/O, no state mutations. Worker owns the side effects.\n */\nexport class GateHandler {\n static readonly uses = 'builtin:gate';\n\n /**\n * @param validRestartTargets Identifiers the worker can reset on restart —\n * step ids/spec.ids within the gate's own job AND other job names (for\n * cross-job restart). When provided and `restartFrom` matches none of them,\n * the gate FAILS instead of attempting a restart the worker cannot apply —\n * which would otherwise silently degrade into \"complete the gate and\n * proceed\" (a false green).\n */\n handle(\n input: GateInput,\n exprCtx: ExpressionContext,\n currentIteration: number,\n validRestartTargets?: string[],\n ): GateDecision {\n const maxIterations = input.maxIterations ?? 3;\n const decisionValue = resolveValue(input.decision, exprCtx);\n const decisionKey = String(decisionValue);\n\n const route: GateRouteAction | undefined =\n input.routes[decisionKey] ?? input.routes[decisionValue as string];\n const action = route ?? input.default ?? 'fail';\n\n if (action === 'continue') {\n return {\n action: 'continue',\n outputs: { decisionValue, action: 'continue', iteration: currentIteration },\n };\n }\n\n if (action === 'fail') {\n const failReason =\n (route as { message?: string } | undefined)?.message ??\n `No matching route for value \"${decisionKey}\"`;\n return {\n action: 'fail',\n error: new Error(`Gate failed: ${failReason}`),\n outputs: { decisionValue, action: 'fail', iteration: currentIteration },\n };\n }\n\n // skipTo action — jump forward, marking intermediate steps as skipped\n if (typeof action === 'object' && 'skipTo' in action) {\n if (validRestartTargets && !validRestartTargets.includes(action.skipTo)) {\n return {\n action: 'fail',\n error: new Error(\n `Gate skipTo \"${action.skipTo}\" matches no step in this job — cannot skip.`,\n ),\n outputs: { decisionValue, action: 'fail', iteration: currentIteration },\n };\n }\n return {\n action: 'skip',\n skipTo: action.skipTo,\n outputs: {\n decisionValue,\n action: 'skip',\n skipTo: action.skipTo,\n iteration: currentIteration,\n },\n };\n }\n\n // restart action\n const restartAction = action as { restartFrom: string; context?: Record<string, unknown> };\n\n // Guard: restartFrom must name something the worker can reset — a step in\n // this job or another job by name. If it matches neither, the gate cannot\n // recover; fail honestly rather than let the worker silently complete the\n // gate and proceed (which would ship unverified work as a false green).\n if (validRestartTargets && !validRestartTargets.includes(restartAction.restartFrom)) {\n return {\n action: 'fail',\n error: new Error(\n `Gate restartFrom \"${restartAction.restartFrom}\" matches no step in this job ` +\n `or any job in the run — cannot restart. Failing instead of silently passing.`,\n ),\n outputs: { decisionValue, action: 'fail', iteration: currentIteration },\n };\n }\n\n const nextIteration = currentIteration + 1;\n\n if (nextIteration >= maxIterations) {\n return {\n action: 'fail',\n error: new Error(`Gate max iterations reached (${maxIterations}) for step`),\n outputs: {\n decisionValue,\n action: 'fail',\n iteration: currentIteration,\n maxIterationsReached: true,\n },\n };\n }\n\n return {\n action: 'restart',\n restartFrom: restartAction.restartFrom,\n context: restartAction.context,\n outputs: {\n decisionValue,\n action: 'restart',\n restartFrom: restartAction.restartFrom,\n iteration: nextIteration,\n },\n nextIteration,\n };\n }\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ShellInput, ShellOutput, default as shell } from './shell.js';
|
|
2
|
+
export { ApprovalHandler, ApprovalInput, ApprovalOutput } from './approval.js';
|
|
3
|
+
export { GateDecision, GateHandler, GateInput, GateOutput, GateRouteAction } from './gate.js';
|
|
4
|
+
import '@kb-labs/plugin-contracts';
|
|
5
|
+
import '@kb-labs/workflow-contracts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { execaCommand } from 'execa';
|
|
2
|
+
import { resolveValue } from '@kb-labs/workflow-contracts';
|
|
3
|
+
|
|
4
|
+
// src/shell.ts
|
|
5
|
+
var BLOCKED_COMMANDS = [
|
|
6
|
+
"rm -rf /",
|
|
7
|
+
"rm -rf /*",
|
|
8
|
+
"mkfs",
|
|
9
|
+
"dd if=",
|
|
10
|
+
":(){:|:&};:",
|
|
11
|
+
// Fork bomb
|
|
12
|
+
"chmod -R 777 /",
|
|
13
|
+
"chown -R",
|
|
14
|
+
"> /dev/sda",
|
|
15
|
+
"mv /* ",
|
|
16
|
+
"fdisk"
|
|
17
|
+
];
|
|
18
|
+
var OUTPUT_MARKER = "::kb-output::";
|
|
19
|
+
var OUTPUT_MARKER_B64 = "::kb-output:base64::";
|
|
20
|
+
function parseOutputMarkerLine(line) {
|
|
21
|
+
const b64Idx = line.indexOf(OUTPUT_MARKER_B64);
|
|
22
|
+
if (b64Idx !== -1) {
|
|
23
|
+
const raw = line.slice(b64Idx + OUTPUT_MARKER_B64.length).trim();
|
|
24
|
+
let decoded;
|
|
25
|
+
try {
|
|
26
|
+
decoded = Buffer.from(raw, "base64").toString("utf8");
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error(`::kb-output:base64:: payload is not valid base64`);
|
|
29
|
+
}
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(decoded);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
35
|
+
throw new Error(`::kb-output:base64:: malformed JSON payload after decode: ${detail}`);
|
|
36
|
+
}
|
|
37
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`::kb-output:base64:: JSON must be an object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
|
41
|
+
}
|
|
42
|
+
const idx = line.indexOf(OUTPUT_MARKER);
|
|
43
|
+
if (idx !== -1) {
|
|
44
|
+
const raw = line.slice(idx + OUTPUT_MARKER.length);
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(raw);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
50
|
+
throw new Error(`::kb-output:: malformed JSON payload: ${detail}`);
|
|
51
|
+
}
|
|
52
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`::kb-output:: JSON must be an object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function mergeJsonOutputs(output, warn) {
|
|
60
|
+
const base = { ...output };
|
|
61
|
+
const trimmed = output.stdout.trim();
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
return base;
|
|
64
|
+
}
|
|
65
|
+
const lines = output.stdout.split("\n");
|
|
66
|
+
let foundMarker = false;
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (!line.includes("::kb-output")) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
foundMarker = true;
|
|
72
|
+
try {
|
|
73
|
+
const parsed = parseOutputMarkerLine(line);
|
|
74
|
+
if (parsed) {
|
|
75
|
+
Object.assign(base, parsed);
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
79
|
+
warn?.(`Malformed ::kb-output:: marker (outputs not populated): ${msg}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (foundMarker) {
|
|
83
|
+
return base;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(trimmed);
|
|
87
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
88
|
+
Object.assign(base, parsed);
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
return base;
|
|
93
|
+
}
|
|
94
|
+
async function shellHandler(ctx, input) {
|
|
95
|
+
const { command, env = {}, timeout = 3e5, throwOnError = false } = input;
|
|
96
|
+
const normalizedCommand = command.toLowerCase().trim();
|
|
97
|
+
for (const blocked of BLOCKED_COMMANDS) {
|
|
98
|
+
if (normalizedCommand.includes(blocked.toLowerCase())) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Dangerous command blocked: "${blocked}". Command attempted: ${command.slice(0, 100)}`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const cwd = ctx.cwd;
|
|
105
|
+
const mergedEnv = {
|
|
106
|
+
...process.env,
|
|
107
|
+
...env
|
|
108
|
+
};
|
|
109
|
+
ctx.platform.logger.info("Executing shell command", {
|
|
110
|
+
command: command.slice(0, 200),
|
|
111
|
+
cwd,
|
|
112
|
+
timeout
|
|
113
|
+
});
|
|
114
|
+
try {
|
|
115
|
+
const proc = execaCommand(command, {
|
|
116
|
+
cwd,
|
|
117
|
+
env: mergedEnv,
|
|
118
|
+
shell: true,
|
|
119
|
+
stdio: "pipe",
|
|
120
|
+
reject: false,
|
|
121
|
+
// We handle exit codes ourselves
|
|
122
|
+
detached: true
|
|
123
|
+
});
|
|
124
|
+
let lineNo = 0;
|
|
125
|
+
let stdoutBuf = "";
|
|
126
|
+
let stderrBuf = "";
|
|
127
|
+
let stdoutFull = "";
|
|
128
|
+
let stderrFull = "";
|
|
129
|
+
const emitLine = (stream, line) => {
|
|
130
|
+
lineNo++;
|
|
131
|
+
void ctx.api.events.emit("log.line", { stream, line, lineNo, level: stream === "stderr" ? "error" : "info" });
|
|
132
|
+
};
|
|
133
|
+
proc.stdout?.on("data", (chunk) => {
|
|
134
|
+
const text = chunk.toString();
|
|
135
|
+
stdoutFull += text;
|
|
136
|
+
stdoutBuf += text;
|
|
137
|
+
const lines = stdoutBuf.split("\n");
|
|
138
|
+
stdoutBuf = lines.pop() ?? "";
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
emitLine("stdout", line);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
proc.stderr?.on("data", (chunk) => {
|
|
144
|
+
const text = chunk.toString();
|
|
145
|
+
stderrFull += text;
|
|
146
|
+
stderrBuf += text;
|
|
147
|
+
const lines = stderrBuf.split("\n");
|
|
148
|
+
stderrBuf = lines.pop() ?? "";
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
emitLine("stderr", line);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
let timedOut = false;
|
|
154
|
+
const killTimer = setTimeout(() => {
|
|
155
|
+
timedOut = true;
|
|
156
|
+
const pid = proc.pid;
|
|
157
|
+
if (pid !== void 0) {
|
|
158
|
+
try {
|
|
159
|
+
process.kill(-pid, "SIGKILL");
|
|
160
|
+
} catch {
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}, timeout);
|
|
164
|
+
const result = await proc;
|
|
165
|
+
clearTimeout(killTimer);
|
|
166
|
+
if (stdoutBuf) {
|
|
167
|
+
emitLine("stdout", stdoutBuf);
|
|
168
|
+
}
|
|
169
|
+
if (stderrBuf) {
|
|
170
|
+
emitLine("stderr", stderrBuf);
|
|
171
|
+
}
|
|
172
|
+
if (timedOut) {
|
|
173
|
+
throw new Error(`Shell command timed out after ${timeout}ms`);
|
|
174
|
+
}
|
|
175
|
+
const output = {
|
|
176
|
+
// Use our accumulated buffers — result.stdout/stderr are empty because
|
|
177
|
+
// we consumed the streams via 'data' listeners above.
|
|
178
|
+
stdout: stdoutFull || result.stdout,
|
|
179
|
+
stderr: stderrFull || result.stderr,
|
|
180
|
+
exitCode: result.exitCode ?? 0,
|
|
181
|
+
ok: (result.exitCode ?? 0) === 0
|
|
182
|
+
};
|
|
183
|
+
if (output.ok) {
|
|
184
|
+
ctx.platform.logger.info("Shell command completed successfully", {
|
|
185
|
+
exitCode: output.exitCode,
|
|
186
|
+
stdoutLines: output.stdout.split("\n").length
|
|
187
|
+
});
|
|
188
|
+
} else {
|
|
189
|
+
ctx.platform.logger.warn("Shell command failed", {
|
|
190
|
+
exitCode: output.exitCode,
|
|
191
|
+
stderrLines: output.stderr.split("\n").length
|
|
192
|
+
});
|
|
193
|
+
if (throwOnError) {
|
|
194
|
+
throw new Error(`Shell command failed with exit code ${output.exitCode}: ${output.stderr.slice(0, 500)}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return mergeJsonOutputs(output, (msg) => ctx.platform.logger.warn(`[shell] ${msg}`));
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error && typeof error === "object" && "timedOut" in error && error.timedOut) {
|
|
200
|
+
throw new Error(`Shell command timed out after ${timeout}ms`);
|
|
201
|
+
}
|
|
202
|
+
if (error && typeof error === "object" && "exitCode" in error) {
|
|
203
|
+
const execError = error;
|
|
204
|
+
const output = {
|
|
205
|
+
stdout: execError.stdout ?? "",
|
|
206
|
+
stderr: execError.stderr ?? "",
|
|
207
|
+
exitCode: execError.exitCode ?? 1,
|
|
208
|
+
ok: false
|
|
209
|
+
};
|
|
210
|
+
ctx.platform.logger.error("Shell command execution failed", void 0, {
|
|
211
|
+
exitCode: output.exitCode,
|
|
212
|
+
stderr: output.stderr.slice(0, 500)
|
|
213
|
+
});
|
|
214
|
+
if (!throwOnError) {
|
|
215
|
+
return { ...output };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
throw error;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
var shell_default = {
|
|
222
|
+
execute: shellHandler
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// src/approval.ts
|
|
226
|
+
var ApprovalHandler = class {
|
|
227
|
+
static uses = "builtin:approval";
|
|
228
|
+
handle(_input) {
|
|
229
|
+
return { status: "waiting", reason: "approval" };
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
var GateHandler = class {
|
|
233
|
+
static uses = "builtin:gate";
|
|
234
|
+
/**
|
|
235
|
+
* @param validRestartTargets Identifiers the worker can reset on restart —
|
|
236
|
+
* step ids/spec.ids within the gate's own job AND other job names (for
|
|
237
|
+
* cross-job restart). When provided and `restartFrom` matches none of them,
|
|
238
|
+
* the gate FAILS instead of attempting a restart the worker cannot apply —
|
|
239
|
+
* which would otherwise silently degrade into "complete the gate and
|
|
240
|
+
* proceed" (a false green).
|
|
241
|
+
*/
|
|
242
|
+
handle(input, exprCtx, currentIteration, validRestartTargets) {
|
|
243
|
+
const maxIterations = input.maxIterations ?? 3;
|
|
244
|
+
const decisionValue = resolveValue(input.decision, exprCtx);
|
|
245
|
+
const decisionKey = String(decisionValue);
|
|
246
|
+
const route = input.routes[decisionKey] ?? input.routes[decisionValue];
|
|
247
|
+
const action = route ?? input.default ?? "fail";
|
|
248
|
+
if (action === "continue") {
|
|
249
|
+
return {
|
|
250
|
+
action: "continue",
|
|
251
|
+
outputs: { decisionValue, action: "continue", iteration: currentIteration }
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (action === "fail") {
|
|
255
|
+
const failReason = route?.message ?? `No matching route for value "${decisionKey}"`;
|
|
256
|
+
return {
|
|
257
|
+
action: "fail",
|
|
258
|
+
error: new Error(`Gate failed: ${failReason}`),
|
|
259
|
+
outputs: { decisionValue, action: "fail", iteration: currentIteration }
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (typeof action === "object" && "skipTo" in action) {
|
|
263
|
+
if (validRestartTargets && !validRestartTargets.includes(action.skipTo)) {
|
|
264
|
+
return {
|
|
265
|
+
action: "fail",
|
|
266
|
+
error: new Error(
|
|
267
|
+
`Gate skipTo "${action.skipTo}" matches no step in this job \u2014 cannot skip.`
|
|
268
|
+
),
|
|
269
|
+
outputs: { decisionValue, action: "fail", iteration: currentIteration }
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
action: "skip",
|
|
274
|
+
skipTo: action.skipTo,
|
|
275
|
+
outputs: {
|
|
276
|
+
decisionValue,
|
|
277
|
+
action: "skip",
|
|
278
|
+
skipTo: action.skipTo,
|
|
279
|
+
iteration: currentIteration
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const restartAction = action;
|
|
284
|
+
if (validRestartTargets && !validRestartTargets.includes(restartAction.restartFrom)) {
|
|
285
|
+
return {
|
|
286
|
+
action: "fail",
|
|
287
|
+
error: new Error(
|
|
288
|
+
`Gate restartFrom "${restartAction.restartFrom}" matches no step in this job or any job in the run \u2014 cannot restart. Failing instead of silently passing.`
|
|
289
|
+
),
|
|
290
|
+
outputs: { decisionValue, action: "fail", iteration: currentIteration }
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const nextIteration = currentIteration + 1;
|
|
294
|
+
if (nextIteration >= maxIterations) {
|
|
295
|
+
return {
|
|
296
|
+
action: "fail",
|
|
297
|
+
error: new Error(`Gate max iterations reached (${maxIterations}) for step`),
|
|
298
|
+
outputs: {
|
|
299
|
+
decisionValue,
|
|
300
|
+
action: "fail",
|
|
301
|
+
iteration: currentIteration,
|
|
302
|
+
maxIterationsReached: true
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
action: "restart",
|
|
308
|
+
restartFrom: restartAction.restartFrom,
|
|
309
|
+
context: restartAction.context,
|
|
310
|
+
outputs: {
|
|
311
|
+
decisionValue,
|
|
312
|
+
action: "restart",
|
|
313
|
+
restartFrom: restartAction.restartFrom,
|
|
314
|
+
iteration: nextIteration
|
|
315
|
+
},
|
|
316
|
+
nextIteration
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
export { ApprovalHandler, GateHandler, shell_default as shell };
|
|
322
|
+
//# sourceMappingURL=index.js.map
|
|
323
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shell.ts","../src/approval.ts","../src/gate.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @module @kb-labs/workflow-runtime/builtin-handlers/shell\n * Built-in shell execution handler for workflows\n *\n * Security features:\n * - Blocks dangerous commands (rm -rf /, fork bombs, etc.)\n * - Timeout enforcement (default 5 minutes)\n * - Environment variable isolation\n * - Working directory restrictions\n */\n\nimport { execaCommand } from 'execa';\nimport type { PluginContextV3 } from '@kb-labs/plugin-contracts';\n\n/**\n * Commands that are always blocked (dangerous)\n */\nconst BLOCKED_COMMANDS = [\n 'rm -rf /',\n 'rm -rf /*',\n 'mkfs',\n 'dd if=',\n ':(){:|:&};:', // Fork bomb\n 'chmod -R 777 /',\n 'chown -R',\n '> /dev/sda',\n 'mv /* ',\n 'fdisk',\n];\n\n/**\n * Split string into chunks of specified size\n */\nfunction chunkString(str: string, chunkSize: number): string[] {\n const chunks: string[] = [];\n for (let i = 0; i < str.length; i += chunkSize) {\n chunks.push(str.slice(i, i + chunkSize));\n }\n return chunks;\n}\n\n/**\n * Shell handler input\n */\nexport interface ShellInput {\n /** Command to execute */\n command: string;\n\n /** Additional environment variables */\n env?: Record<string, string>;\n\n /** Timeout in milliseconds (default: 300000 = 5 min) */\n timeout?: number;\n\n /** Throw on non-zero exit code (default: false) */\n throwOnError?: boolean;\n}\n\n/**\n * Shell handler output\n */\nexport interface ShellOutput {\n /** Standard output */\n stdout: string;\n\n /** Standard error */\n stderr: string;\n\n /** Exit code */\n exitCode: number;\n\n /** Whether command succeeded (exitCode === 0) */\n ok: boolean;\n}\n\n/**\n * Plain-JSON output marker.\n * echo '::kb-output::{\"passed\":true}'\n *\n * Only safe for values without embedded newlines, quotes, or control chars.\n * For multi-line text, binary data, or any value produced by a sub-command,\n * prefer the base64 variant below.\n */\nconst OUTPUT_MARKER = '::kb-output::';\n\n/**\n * Base64-encoded JSON output marker — JSON-safe for any payload.\n *\n * Usage (shell):\n * PAYLOAD=$(jq -cn --arg plan \"$(cat /tmp/plan.md)\" '{plan: $plan}' | base64 -w0)\n * echo \"::kb-output:base64::${PAYLOAD}\"\n *\n * This handles multi-line text, special chars, and any binary-safe content.\n * The base64 string is decoded and JSON-parsed by the runtime.\n */\nconst OUTPUT_MARKER_B64 = '::kb-output:base64::';\n\n/**\n * Parse a single ::kb-output:: or ::kb-output:base64:: marker line into key-value pairs.\n *\n * Returns the parsed object, or null if the line is not a recognized marker.\n * Throws a descriptive error if the marker payload is malformed so callers can warn.\n */\nexport function parseOutputMarkerLine(line: string): Record<string, unknown> | null {\n // Base64-encoded variant — JSON-safe for any payload\n const b64Idx = line.indexOf(OUTPUT_MARKER_B64);\n if (b64Idx !== -1) {\n const raw = line.slice(b64Idx + OUTPUT_MARKER_B64.length).trim();\n let decoded: string;\n try {\n decoded = Buffer.from(raw, 'base64').toString('utf8');\n } catch {\n throw new Error(`::kb-output:base64:: payload is not valid base64`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(decoded);\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n throw new Error(`::kb-output:base64:: malformed JSON payload after decode: ${detail}`);\n }\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n throw new Error(`::kb-output:base64:: JSON must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);\n }\n\n // Plain-JSON variant — only safe for simple scalar values\n const idx = line.indexOf(OUTPUT_MARKER);\n if (idx !== -1) {\n const raw = line.slice(idx + OUTPUT_MARKER.length);\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n throw new Error(`::kb-output:: malformed JSON payload: ${detail}`);\n }\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n throw new Error(`::kb-output:: JSON must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);\n }\n\n return null;\n}\n\n/**\n * Extract structured outputs from shell stdout.\n *\n * Priority:\n * 1. ::kb-output:base64:: marker lines — JSON-safe, recommended for complex values\n * 2. ::kb-output:: marker lines — plain JSON, ok for simple scalars\n * 3. Entire stdout as JSON — fallback for backward compat (simple commands)\n *\n * Malformed markers emit a warning instead of silently dropping the output.\n * Logs and other stdout content are ignored for output purposes.\n */\nexport function mergeJsonOutputs(output: ShellOutput, warn?: (msg: string) => void): Record<string, unknown> {\n const base: Record<string, unknown> = { ...output };\n const trimmed = output.stdout.trim();\n if (!trimmed) {return base;}\n\n // Priority 1 + 2: Look for ::kb-output:: or ::kb-output:base64:: marker lines\n const lines = output.stdout.split('\\n');\n let foundMarker = false;\n for (const line of lines) {\n if (!line.includes('::kb-output')) {continue;}\n foundMarker = true;\n try {\n const parsed = parseOutputMarkerLine(line);\n if (parsed) {\n Object.assign(base, parsed);\n }\n } catch (err) {\n // Malformed marker — warn instead of silently dropping\n const msg = err instanceof Error ? err.message : String(err);\n warn?.(`Malformed ::kb-output:: marker (outputs not populated): ${msg}`);\n }\n }\n\n if (foundMarker) {return base;}\n\n // Priority 3: Fallback — entire stdout as JSON (backward compat)\n try {\n const parsed: unknown = JSON.parse(trimmed);\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n Object.assign(base, parsed as Record<string, unknown>);\n }\n } catch {\n // Not JSON — return as-is\n }\n\n return base;\n}\n\n/**\n * Built-in shell execution handler.\n *\n * Executes shell commands with safety checks and timeout enforcement.\n *\n * @param ctx - Handler execution context\n * @param input - Shell command input\n * @returns Shell execution result\n * @throws Error if dangerous command detected or timeout exceeded\n */\nasync function shellHandler(\n ctx: PluginContextV3,\n input: ShellInput,\n): Promise<Record<string, unknown>> {\n const { command, env = {}, timeout = 300000, throwOnError = false } = input;\n\n // Security: Check for dangerous commands\n const normalizedCommand = command.toLowerCase().trim();\n for (const blocked of BLOCKED_COMMANDS) {\n if (normalizedCommand.includes(blocked.toLowerCase())) {\n throw new Error(\n `Dangerous command blocked: \"${blocked}\". Command attempted: ${command.slice(0, 100)}`,\n );\n }\n }\n\n // Get working directory from context (workflow workspace)\n const cwd = ctx.cwd;\n\n // Merge environment variables\n const mergedEnv = {\n ...process.env,\n ...env,\n };\n\n ctx.platform.logger.info('Executing shell command', {\n command: command.slice(0, 200),\n cwd,\n timeout,\n });\n\n try {\n // detached: true puts the subprocess in its own process group. On timeout we kill\n // the entire group (process.kill(-pid, 'SIGKILL')), which takes down both the shell\n // and any children it spawned. Without this, SIGTERM reaches only the shell; child\n // processes (e.g. `sleep N`) inherit the pipe write-end and block await indefinitely.\n const proc = execaCommand(command, {\n cwd,\n env: mergedEnv,\n shell: true,\n stdio: 'pipe',\n reject: false, // We handle exit codes ourselves\n detached: true,\n });\n\n // Stream stdout/stderr line-by-line in real-time.\n // We consume the streams ourselves via 'data' events for live log streaming.\n // Because attaching a 'data' listener drains the stream, execa's result.stdout\n // will be empty — we must reconstruct the full output from collected chunks.\n let lineNo = 0;\n let stdoutBuf = '';\n let stderrBuf = '';\n let stdoutFull = '';\n let stderrFull = '';\n\n const emitLine = (stream: 'stdout' | 'stderr', line: string) => {\n lineNo++;\n void ctx.api.events.emit('log.line', { stream, line, lineNo, level: stream === 'stderr' ? 'error' : 'info' });\n };\n\n proc.stdout?.on('data', (chunk: Buffer) => {\n const text = chunk.toString();\n stdoutFull += text;\n stdoutBuf += text;\n const lines = stdoutBuf.split('\\n');\n stdoutBuf = lines.pop() ?? '';\n for (const line of lines) {emitLine('stdout', line);}\n });\n\n proc.stderr?.on('data', (chunk: Buffer) => {\n const text = chunk.toString();\n stderrFull += text;\n stderrBuf += text;\n const lines = stderrBuf.split('\\n');\n stderrBuf = lines.pop() ?? '';\n for (const line of lines) {emitLine('stderr', line);}\n });\n\n // Self-managed timeout: kill the entire process group so orphaned children\n // release the pipe write-end and await proc resolves immediately.\n let timedOut = false;\n const killTimer = setTimeout(() => {\n timedOut = true;\n const pid = proc.pid;\n if (pid !== undefined) {\n try {\n process.kill(-pid, 'SIGKILL');\n } catch {\n // process already exited\n }\n }\n }, timeout);\n\n const result = await proc;\n clearTimeout(killTimer);\n\n // Flush remaining buffered content\n if (stdoutBuf) {emitLine('stdout', stdoutBuf);}\n if (stderrBuf) {emitLine('stderr', stderrBuf);}\n\n if (timedOut) {\n throw new Error(`Shell command timed out after ${timeout}ms`);\n }\n\n const output: ShellOutput = {\n // Use our accumulated buffers — result.stdout/stderr are empty because\n // we consumed the streams via 'data' listeners above.\n stdout: stdoutFull || result.stdout,\n stderr: stderrFull || result.stderr,\n exitCode: result.exitCode ?? 0,\n ok: (result.exitCode ?? 0) === 0,\n };\n\n if (output.ok) {\n ctx.platform.logger.info('Shell command completed successfully', {\n exitCode: output.exitCode,\n stdoutLines: output.stdout.split('\\n').length,\n });\n } else {\n ctx.platform.logger.warn('Shell command failed', {\n exitCode: output.exitCode,\n stderrLines: output.stderr.split('\\n').length,\n });\n\n if (throwOnError) {\n throw new Error(`Shell command failed with exit code ${output.exitCode}: ${output.stderr.slice(0, 500)}`);\n }\n }\n\n return mergeJsonOutputs(output, (msg) => ctx.platform.logger.warn(`[shell] ${msg}`));\n } catch (error) {\n // Handle timeout\n if (error && typeof error === 'object' && 'timedOut' in error && error.timedOut) {\n throw new Error(`Shell command timed out after ${timeout}ms`);\n }\n\n // Handle execution error\n if (error && typeof error === 'object' && 'exitCode' in error) {\n const execError = error as { exitCode?: number; stdout?: string; stderr?: string };\n const output: ShellOutput = {\n stdout: execError.stdout ?? '',\n stderr: execError.stderr ?? '',\n exitCode: execError.exitCode ?? 1,\n ok: false,\n };\n\n ctx.platform.logger.error('Shell command execution failed', undefined, {\n exitCode: output.exitCode,\n stderr: output.stderr.slice(0, 500),\n });\n\n if (!throwOnError) {\n return { ...output };\n }\n }\n\n throw error;\n }\n}\n\n// Export handler in format expected by ExecutionBackend\nexport default {\n execute: shellHandler,\n};\n","/**\n * @module @kb-labs/workflow-steps/approval\n * Types and handler for builtin:approval step\n *\n * Approval steps pause the pipeline and wait for human decision.\n * The worker handles polling; resolveApproval() on the engine resumes execution.\n */\n\n/**\n * Input for builtin:approval step (spec.with)\n */\nexport interface ApprovalInput {\n /** Display title for the approval request */\n title: string;\n\n /** Contextual data shown to the approver (already interpolated) */\n context?: Record<string, unknown>;\n\n /** Optional instructions for the approver */\n instructions?: string;\n}\n\n/**\n * Output produced by a resolved approval step\n */\nexport interface ApprovalOutput {\n /** Whether the approval was granted */\n approved: boolean;\n\n /** Action taken: \"approve\" or \"reject\" */\n action: 'approve' | 'reject';\n\n /** Optional comment from the approver */\n comment?: string;\n\n /** Additional data provided by the approver */\n [key: string]: unknown;\n}\n\n/**\n * ApprovalHandler — signals the worker to pause and wait for human approval.\n * The worker owns the polling loop; this handler only marks the intent.\n */\nexport class ApprovalHandler {\n static readonly uses = 'builtin:approval';\n\n handle(_input: ApprovalInput): { status: 'waiting'; reason: 'approval' } {\n return { status: 'waiting', reason: 'approval' };\n }\n}\n","/**\n * @module @kb-labs/workflow-steps/gate\n * Types and handler for builtin:gate step\n *\n * Gate steps act as automatic routers — they read a decision value\n * from previous step outputs and route the pipeline accordingly:\n * - continue: proceed to next step\n * - fail: fail the pipeline\n * - restart: reset steps back to target and re-schedule with context\n * - skip: mark intermediate steps as skipped and jump forward to a target step\n */\n\nimport { resolveValue, type ExpressionContext } from '@kb-labs/workflow-contracts';\n\n/**\n * Route action for a gate decision\n */\nexport type GateRouteAction =\n | 'continue'\n | 'fail'\n | {\n /** Step ID to restart from (go backward) */\n restartFrom: string;\n /** Additional context to pass (merged into trigger.payload) */\n context?: Record<string, unknown>;\n }\n | {\n /** Step ID to skip forward to — all steps between gate and target are marked skipped */\n skipTo: string;\n };\n\n/**\n * Input for builtin:gate step (spec.with)\n */\nexport interface GateInput {\n /** Expression path to the decision value (e.g. \"steps.review.outputs.passed\") */\n decision: string;\n\n /** Route map: decision value → action */\n routes: Record<string, GateRouteAction>;\n\n /** Default action if decision value doesn't match any route */\n default?: 'continue' | 'fail';\n\n /** Maximum number of restart iterations before failing (default: 3) */\n maxIterations?: number;\n}\n\n/**\n * Output produced by a resolved gate step\n */\nexport interface GateOutput {\n /** The decision value that was evaluated */\n decisionValue: unknown;\n\n /** The action that was taken */\n action: 'continue' | 'fail' | 'restart' | 'skip';\n\n /** Step ID that was restarted from (if restart) */\n restartFrom?: string;\n\n /** Step ID that was skipped to (if skip) */\n skipTo?: string;\n\n /** Current iteration count */\n iteration: number;\n\n /** Set to true when the gate exhausted its maxIterations budget */\n maxIterationsReached?: boolean;\n\n [key: string]: unknown;\n}\n\n/**\n * Pure decision result from GateHandler.\n * Worker applies the state mutations based on the action.\n */\nexport type GateDecision =\n | { action: 'continue'; outputs: GateOutput }\n | { action: 'fail'; error: Error; outputs: GateOutput }\n | {\n action: 'restart';\n restartFrom: string;\n context?: Record<string, unknown>;\n outputs: GateOutput;\n nextIteration: number;\n }\n | {\n action: 'skip';\n skipTo: string;\n outputs: GateOutput;\n };\n\n/**\n * GateHandler — pure decision function.\n * No I/O, no state mutations. Worker owns the side effects.\n */\nexport class GateHandler {\n static readonly uses = 'builtin:gate';\n\n /**\n * @param validRestartTargets Identifiers the worker can reset on restart —\n * step ids/spec.ids within the gate's own job AND other job names (for\n * cross-job restart). When provided and `restartFrom` matches none of them,\n * the gate FAILS instead of attempting a restart the worker cannot apply —\n * which would otherwise silently degrade into \"complete the gate and\n * proceed\" (a false green).\n */\n handle(\n input: GateInput,\n exprCtx: ExpressionContext,\n currentIteration: number,\n validRestartTargets?: string[],\n ): GateDecision {\n const maxIterations = input.maxIterations ?? 3;\n const decisionValue = resolveValue(input.decision, exprCtx);\n const decisionKey = String(decisionValue);\n\n const route: GateRouteAction | undefined =\n input.routes[decisionKey] ?? input.routes[decisionValue as string];\n const action = route ?? input.default ?? 'fail';\n\n if (action === 'continue') {\n return {\n action: 'continue',\n outputs: { decisionValue, action: 'continue', iteration: currentIteration },\n };\n }\n\n if (action === 'fail') {\n const failReason =\n (route as { message?: string } | undefined)?.message ??\n `No matching route for value \"${decisionKey}\"`;\n return {\n action: 'fail',\n error: new Error(`Gate failed: ${failReason}`),\n outputs: { decisionValue, action: 'fail', iteration: currentIteration },\n };\n }\n\n // skipTo action — jump forward, marking intermediate steps as skipped\n if (typeof action === 'object' && 'skipTo' in action) {\n if (validRestartTargets && !validRestartTargets.includes(action.skipTo)) {\n return {\n action: 'fail',\n error: new Error(\n `Gate skipTo \"${action.skipTo}\" matches no step in this job — cannot skip.`,\n ),\n outputs: { decisionValue, action: 'fail', iteration: currentIteration },\n };\n }\n return {\n action: 'skip',\n skipTo: action.skipTo,\n outputs: {\n decisionValue,\n action: 'skip',\n skipTo: action.skipTo,\n iteration: currentIteration,\n },\n };\n }\n\n // restart action\n const restartAction = action as { restartFrom: string; context?: Record<string, unknown> };\n\n // Guard: restartFrom must name something the worker can reset — a step in\n // this job or another job by name. If it matches neither, the gate cannot\n // recover; fail honestly rather than let the worker silently complete the\n // gate and proceed (which would ship unverified work as a false green).\n if (validRestartTargets && !validRestartTargets.includes(restartAction.restartFrom)) {\n return {\n action: 'fail',\n error: new Error(\n `Gate restartFrom \"${restartAction.restartFrom}\" matches no step in this job ` +\n `or any job in the run — cannot restart. Failing instead of silently passing.`,\n ),\n outputs: { decisionValue, action: 'fail', iteration: currentIteration },\n };\n }\n\n const nextIteration = currentIteration + 1;\n\n if (nextIteration >= maxIterations) {\n return {\n action: 'fail',\n error: new Error(`Gate max iterations reached (${maxIterations}) for step`),\n outputs: {\n decisionValue,\n action: 'fail',\n iteration: currentIteration,\n maxIterationsReached: true,\n },\n };\n }\n\n return {\n action: 'restart',\n restartFrom: restartAction.restartFrom,\n context: restartAction.context,\n outputs: {\n decisionValue,\n action: 'restart',\n restartFrom: restartAction.restartFrom,\n iteration: nextIteration,\n },\n nextIteration,\n };\n }\n}\n"]}
|
package/dist/shell.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { PluginContextV3 } from '@kb-labs/plugin-contracts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module @kb-labs/workflow-runtime/builtin-handlers/shell
|
|
5
|
+
* Built-in shell execution handler for workflows
|
|
6
|
+
*
|
|
7
|
+
* Security features:
|
|
8
|
+
* - Blocks dangerous commands (rm -rf /, fork bombs, etc.)
|
|
9
|
+
* - Timeout enforcement (default 5 minutes)
|
|
10
|
+
* - Environment variable isolation
|
|
11
|
+
* - Working directory restrictions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shell handler input
|
|
16
|
+
*/
|
|
17
|
+
interface ShellInput {
|
|
18
|
+
/** Command to execute */
|
|
19
|
+
command: string;
|
|
20
|
+
/** Additional environment variables */
|
|
21
|
+
env?: Record<string, string>;
|
|
22
|
+
/** Timeout in milliseconds (default: 300000 = 5 min) */
|
|
23
|
+
timeout?: number;
|
|
24
|
+
/** Throw on non-zero exit code (default: false) */
|
|
25
|
+
throwOnError?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Shell handler output
|
|
29
|
+
*/
|
|
30
|
+
interface ShellOutput {
|
|
31
|
+
/** Standard output */
|
|
32
|
+
stdout: string;
|
|
33
|
+
/** Standard error */
|
|
34
|
+
stderr: string;
|
|
35
|
+
/** Exit code */
|
|
36
|
+
exitCode: number;
|
|
37
|
+
/** Whether command succeeded (exitCode === 0) */
|
|
38
|
+
ok: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Parse a single ::kb-output:: or ::kb-output:base64:: marker line into key-value pairs.
|
|
42
|
+
*
|
|
43
|
+
* Returns the parsed object, or null if the line is not a recognized marker.
|
|
44
|
+
* Throws a descriptive error if the marker payload is malformed so callers can warn.
|
|
45
|
+
*/
|
|
46
|
+
declare function parseOutputMarkerLine(line: string): Record<string, unknown> | null;
|
|
47
|
+
/**
|
|
48
|
+
* Extract structured outputs from shell stdout.
|
|
49
|
+
*
|
|
50
|
+
* Priority:
|
|
51
|
+
* 1. ::kb-output:base64:: marker lines — JSON-safe, recommended for complex values
|
|
52
|
+
* 2. ::kb-output:: marker lines — plain JSON, ok for simple scalars
|
|
53
|
+
* 3. Entire stdout as JSON — fallback for backward compat (simple commands)
|
|
54
|
+
*
|
|
55
|
+
* Malformed markers emit a warning instead of silently dropping the output.
|
|
56
|
+
* Logs and other stdout content are ignored for output purposes.
|
|
57
|
+
*/
|
|
58
|
+
declare function mergeJsonOutputs(output: ShellOutput, warn?: (msg: string) => void): Record<string, unknown>;
|
|
59
|
+
/**
|
|
60
|
+
* Built-in shell execution handler.
|
|
61
|
+
*
|
|
62
|
+
* Executes shell commands with safety checks and timeout enforcement.
|
|
63
|
+
*
|
|
64
|
+
* @param ctx - Handler execution context
|
|
65
|
+
* @param input - Shell command input
|
|
66
|
+
* @returns Shell execution result
|
|
67
|
+
* @throws Error if dangerous command detected or timeout exceeded
|
|
68
|
+
*/
|
|
69
|
+
declare function shellHandler(ctx: PluginContextV3, input: ShellInput): Promise<Record<string, unknown>>;
|
|
70
|
+
declare const _default: {
|
|
71
|
+
execute: typeof shellHandler;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export { type ShellInput, type ShellOutput, _default as default, mergeJsonOutputs, parseOutputMarkerLine };
|
package/dist/shell.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { execaCommand } from 'execa';
|
|
2
|
+
|
|
3
|
+
// src/shell.ts
|
|
4
|
+
var BLOCKED_COMMANDS = [
|
|
5
|
+
"rm -rf /",
|
|
6
|
+
"rm -rf /*",
|
|
7
|
+
"mkfs",
|
|
8
|
+
"dd if=",
|
|
9
|
+
":(){:|:&};:",
|
|
10
|
+
// Fork bomb
|
|
11
|
+
"chmod -R 777 /",
|
|
12
|
+
"chown -R",
|
|
13
|
+
"> /dev/sda",
|
|
14
|
+
"mv /* ",
|
|
15
|
+
"fdisk"
|
|
16
|
+
];
|
|
17
|
+
var OUTPUT_MARKER = "::kb-output::";
|
|
18
|
+
var OUTPUT_MARKER_B64 = "::kb-output:base64::";
|
|
19
|
+
function parseOutputMarkerLine(line) {
|
|
20
|
+
const b64Idx = line.indexOf(OUTPUT_MARKER_B64);
|
|
21
|
+
if (b64Idx !== -1) {
|
|
22
|
+
const raw = line.slice(b64Idx + OUTPUT_MARKER_B64.length).trim();
|
|
23
|
+
let decoded;
|
|
24
|
+
try {
|
|
25
|
+
decoded = Buffer.from(raw, "base64").toString("utf8");
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error(`::kb-output:base64:: payload is not valid base64`);
|
|
28
|
+
}
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(decoded);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
34
|
+
throw new Error(`::kb-output:base64:: malformed JSON payload after decode: ${detail}`);
|
|
35
|
+
}
|
|
36
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`::kb-output:base64:: JSON must be an object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
|
40
|
+
}
|
|
41
|
+
const idx = line.indexOf(OUTPUT_MARKER);
|
|
42
|
+
if (idx !== -1) {
|
|
43
|
+
const raw = line.slice(idx + OUTPUT_MARKER.length);
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(raw);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
49
|
+
throw new Error(`::kb-output:: malformed JSON payload: ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`::kb-output:: JSON must be an object, got ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function mergeJsonOutputs(output, warn) {
|
|
59
|
+
const base = { ...output };
|
|
60
|
+
const trimmed = output.stdout.trim();
|
|
61
|
+
if (!trimmed) {
|
|
62
|
+
return base;
|
|
63
|
+
}
|
|
64
|
+
const lines = output.stdout.split("\n");
|
|
65
|
+
let foundMarker = false;
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (!line.includes("::kb-output")) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
foundMarker = true;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = parseOutputMarkerLine(line);
|
|
73
|
+
if (parsed) {
|
|
74
|
+
Object.assign(base, parsed);
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
78
|
+
warn?.(`Malformed ::kb-output:: marker (outputs not populated): ${msg}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (foundMarker) {
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(trimmed);
|
|
86
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
87
|
+
Object.assign(base, parsed);
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
return base;
|
|
92
|
+
}
|
|
93
|
+
async function shellHandler(ctx, input) {
|
|
94
|
+
const { command, env = {}, timeout = 3e5, throwOnError = false } = input;
|
|
95
|
+
const normalizedCommand = command.toLowerCase().trim();
|
|
96
|
+
for (const blocked of BLOCKED_COMMANDS) {
|
|
97
|
+
if (normalizedCommand.includes(blocked.toLowerCase())) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Dangerous command blocked: "${blocked}". Command attempted: ${command.slice(0, 100)}`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const cwd = ctx.cwd;
|
|
104
|
+
const mergedEnv = {
|
|
105
|
+
...process.env,
|
|
106
|
+
...env
|
|
107
|
+
};
|
|
108
|
+
ctx.platform.logger.info("Executing shell command", {
|
|
109
|
+
command: command.slice(0, 200),
|
|
110
|
+
cwd,
|
|
111
|
+
timeout
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
const proc = execaCommand(command, {
|
|
115
|
+
cwd,
|
|
116
|
+
env: mergedEnv,
|
|
117
|
+
shell: true,
|
|
118
|
+
stdio: "pipe",
|
|
119
|
+
reject: false,
|
|
120
|
+
// We handle exit codes ourselves
|
|
121
|
+
detached: true
|
|
122
|
+
});
|
|
123
|
+
let lineNo = 0;
|
|
124
|
+
let stdoutBuf = "";
|
|
125
|
+
let stderrBuf = "";
|
|
126
|
+
let stdoutFull = "";
|
|
127
|
+
let stderrFull = "";
|
|
128
|
+
const emitLine = (stream, line) => {
|
|
129
|
+
lineNo++;
|
|
130
|
+
void ctx.api.events.emit("log.line", { stream, line, lineNo, level: stream === "stderr" ? "error" : "info" });
|
|
131
|
+
};
|
|
132
|
+
proc.stdout?.on("data", (chunk) => {
|
|
133
|
+
const text = chunk.toString();
|
|
134
|
+
stdoutFull += text;
|
|
135
|
+
stdoutBuf += text;
|
|
136
|
+
const lines = stdoutBuf.split("\n");
|
|
137
|
+
stdoutBuf = lines.pop() ?? "";
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
emitLine("stdout", line);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
proc.stderr?.on("data", (chunk) => {
|
|
143
|
+
const text = chunk.toString();
|
|
144
|
+
stderrFull += text;
|
|
145
|
+
stderrBuf += text;
|
|
146
|
+
const lines = stderrBuf.split("\n");
|
|
147
|
+
stderrBuf = lines.pop() ?? "";
|
|
148
|
+
for (const line of lines) {
|
|
149
|
+
emitLine("stderr", line);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
let timedOut = false;
|
|
153
|
+
const killTimer = setTimeout(() => {
|
|
154
|
+
timedOut = true;
|
|
155
|
+
const pid = proc.pid;
|
|
156
|
+
if (pid !== void 0) {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(-pid, "SIGKILL");
|
|
159
|
+
} catch {
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}, timeout);
|
|
163
|
+
const result = await proc;
|
|
164
|
+
clearTimeout(killTimer);
|
|
165
|
+
if (stdoutBuf) {
|
|
166
|
+
emitLine("stdout", stdoutBuf);
|
|
167
|
+
}
|
|
168
|
+
if (stderrBuf) {
|
|
169
|
+
emitLine("stderr", stderrBuf);
|
|
170
|
+
}
|
|
171
|
+
if (timedOut) {
|
|
172
|
+
throw new Error(`Shell command timed out after ${timeout}ms`);
|
|
173
|
+
}
|
|
174
|
+
const output = {
|
|
175
|
+
// Use our accumulated buffers — result.stdout/stderr are empty because
|
|
176
|
+
// we consumed the streams via 'data' listeners above.
|
|
177
|
+
stdout: stdoutFull || result.stdout,
|
|
178
|
+
stderr: stderrFull || result.stderr,
|
|
179
|
+
exitCode: result.exitCode ?? 0,
|
|
180
|
+
ok: (result.exitCode ?? 0) === 0
|
|
181
|
+
};
|
|
182
|
+
if (output.ok) {
|
|
183
|
+
ctx.platform.logger.info("Shell command completed successfully", {
|
|
184
|
+
exitCode: output.exitCode,
|
|
185
|
+
stdoutLines: output.stdout.split("\n").length
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
ctx.platform.logger.warn("Shell command failed", {
|
|
189
|
+
exitCode: output.exitCode,
|
|
190
|
+
stderrLines: output.stderr.split("\n").length
|
|
191
|
+
});
|
|
192
|
+
if (throwOnError) {
|
|
193
|
+
throw new Error(`Shell command failed with exit code ${output.exitCode}: ${output.stderr.slice(0, 500)}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return mergeJsonOutputs(output, (msg) => ctx.platform.logger.warn(`[shell] ${msg}`));
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error && typeof error === "object" && "timedOut" in error && error.timedOut) {
|
|
199
|
+
throw new Error(`Shell command timed out after ${timeout}ms`);
|
|
200
|
+
}
|
|
201
|
+
if (error && typeof error === "object" && "exitCode" in error) {
|
|
202
|
+
const execError = error;
|
|
203
|
+
const output = {
|
|
204
|
+
stdout: execError.stdout ?? "",
|
|
205
|
+
stderr: execError.stderr ?? "",
|
|
206
|
+
exitCode: execError.exitCode ?? 1,
|
|
207
|
+
ok: false
|
|
208
|
+
};
|
|
209
|
+
ctx.platform.logger.error("Shell command execution failed", void 0, {
|
|
210
|
+
exitCode: output.exitCode,
|
|
211
|
+
stderr: output.stderr.slice(0, 500)
|
|
212
|
+
});
|
|
213
|
+
if (!throwOnError) {
|
|
214
|
+
return { ...output };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
var shell_default = {
|
|
221
|
+
execute: shellHandler
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export { shell_default as default, mergeJsonOutputs, parseOutputMarkerLine };
|
|
225
|
+
//# sourceMappingURL=shell.js.map
|
|
226
|
+
//# sourceMappingURL=shell.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shell.ts"],"names":[],"mappings":"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","file":"shell.js","sourcesContent":["/**\n * @module @kb-labs/workflow-runtime/builtin-handlers/shell\n * Built-in shell execution handler for workflows\n *\n * Security features:\n * - Blocks dangerous commands (rm -rf /, fork bombs, etc.)\n * - Timeout enforcement (default 5 minutes)\n * - Environment variable isolation\n * - Working directory restrictions\n */\n\nimport { execaCommand } from 'execa';\nimport type { PluginContextV3 } from '@kb-labs/plugin-contracts';\n\n/**\n * Commands that are always blocked (dangerous)\n */\nconst BLOCKED_COMMANDS = [\n 'rm -rf /',\n 'rm -rf /*',\n 'mkfs',\n 'dd if=',\n ':(){:|:&};:', // Fork bomb\n 'chmod -R 777 /',\n 'chown -R',\n '> /dev/sda',\n 'mv /* ',\n 'fdisk',\n];\n\n/**\n * Split string into chunks of specified size\n */\nfunction chunkString(str: string, chunkSize: number): string[] {\n const chunks: string[] = [];\n for (let i = 0; i < str.length; i += chunkSize) {\n chunks.push(str.slice(i, i + chunkSize));\n }\n return chunks;\n}\n\n/**\n * Shell handler input\n */\nexport interface ShellInput {\n /** Command to execute */\n command: string;\n\n /** Additional environment variables */\n env?: Record<string, string>;\n\n /** Timeout in milliseconds (default: 300000 = 5 min) */\n timeout?: number;\n\n /** Throw on non-zero exit code (default: false) */\n throwOnError?: boolean;\n}\n\n/**\n * Shell handler output\n */\nexport interface ShellOutput {\n /** Standard output */\n stdout: string;\n\n /** Standard error */\n stderr: string;\n\n /** Exit code */\n exitCode: number;\n\n /** Whether command succeeded (exitCode === 0) */\n ok: boolean;\n}\n\n/**\n * Plain-JSON output marker.\n * echo '::kb-output::{\"passed\":true}'\n *\n * Only safe for values without embedded newlines, quotes, or control chars.\n * For multi-line text, binary data, or any value produced by a sub-command,\n * prefer the base64 variant below.\n */\nconst OUTPUT_MARKER = '::kb-output::';\n\n/**\n * Base64-encoded JSON output marker — JSON-safe for any payload.\n *\n * Usage (shell):\n * PAYLOAD=$(jq -cn --arg plan \"$(cat /tmp/plan.md)\" '{plan: $plan}' | base64 -w0)\n * echo \"::kb-output:base64::${PAYLOAD}\"\n *\n * This handles multi-line text, special chars, and any binary-safe content.\n * The base64 string is decoded and JSON-parsed by the runtime.\n */\nconst OUTPUT_MARKER_B64 = '::kb-output:base64::';\n\n/**\n * Parse a single ::kb-output:: or ::kb-output:base64:: marker line into key-value pairs.\n *\n * Returns the parsed object, or null if the line is not a recognized marker.\n * Throws a descriptive error if the marker payload is malformed so callers can warn.\n */\nexport function parseOutputMarkerLine(line: string): Record<string, unknown> | null {\n // Base64-encoded variant — JSON-safe for any payload\n const b64Idx = line.indexOf(OUTPUT_MARKER_B64);\n if (b64Idx !== -1) {\n const raw = line.slice(b64Idx + OUTPUT_MARKER_B64.length).trim();\n let decoded: string;\n try {\n decoded = Buffer.from(raw, 'base64').toString('utf8');\n } catch {\n throw new Error(`::kb-output:base64:: payload is not valid base64`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(decoded);\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n throw new Error(`::kb-output:base64:: malformed JSON payload after decode: ${detail}`);\n }\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n throw new Error(`::kb-output:base64:: JSON must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);\n }\n\n // Plain-JSON variant — only safe for simple scalar values\n const idx = line.indexOf(OUTPUT_MARKER);\n if (idx !== -1) {\n const raw = line.slice(idx + OUTPUT_MARKER.length);\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch (err) {\n const detail = err instanceof Error ? err.message : String(err);\n throw new Error(`::kb-output:: malformed JSON payload: ${detail}`);\n }\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n throw new Error(`::kb-output:: JSON must be an object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);\n }\n\n return null;\n}\n\n/**\n * Extract structured outputs from shell stdout.\n *\n * Priority:\n * 1. ::kb-output:base64:: marker lines — JSON-safe, recommended for complex values\n * 2. ::kb-output:: marker lines — plain JSON, ok for simple scalars\n * 3. Entire stdout as JSON — fallback for backward compat (simple commands)\n *\n * Malformed markers emit a warning instead of silently dropping the output.\n * Logs and other stdout content are ignored for output purposes.\n */\nexport function mergeJsonOutputs(output: ShellOutput, warn?: (msg: string) => void): Record<string, unknown> {\n const base: Record<string, unknown> = { ...output };\n const trimmed = output.stdout.trim();\n if (!trimmed) {return base;}\n\n // Priority 1 + 2: Look for ::kb-output:: or ::kb-output:base64:: marker lines\n const lines = output.stdout.split('\\n');\n let foundMarker = false;\n for (const line of lines) {\n if (!line.includes('::kb-output')) {continue;}\n foundMarker = true;\n try {\n const parsed = parseOutputMarkerLine(line);\n if (parsed) {\n Object.assign(base, parsed);\n }\n } catch (err) {\n // Malformed marker — warn instead of silently dropping\n const msg = err instanceof Error ? err.message : String(err);\n warn?.(`Malformed ::kb-output:: marker (outputs not populated): ${msg}`);\n }\n }\n\n if (foundMarker) {return base;}\n\n // Priority 3: Fallback — entire stdout as JSON (backward compat)\n try {\n const parsed: unknown = JSON.parse(trimmed);\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n Object.assign(base, parsed as Record<string, unknown>);\n }\n } catch {\n // Not JSON — return as-is\n }\n\n return base;\n}\n\n/**\n * Built-in shell execution handler.\n *\n * Executes shell commands with safety checks and timeout enforcement.\n *\n * @param ctx - Handler execution context\n * @param input - Shell command input\n * @returns Shell execution result\n * @throws Error if dangerous command detected or timeout exceeded\n */\nasync function shellHandler(\n ctx: PluginContextV3,\n input: ShellInput,\n): Promise<Record<string, unknown>> {\n const { command, env = {}, timeout = 300000, throwOnError = false } = input;\n\n // Security: Check for dangerous commands\n const normalizedCommand = command.toLowerCase().trim();\n for (const blocked of BLOCKED_COMMANDS) {\n if (normalizedCommand.includes(blocked.toLowerCase())) {\n throw new Error(\n `Dangerous command blocked: \"${blocked}\". Command attempted: ${command.slice(0, 100)}`,\n );\n }\n }\n\n // Get working directory from context (workflow workspace)\n const cwd = ctx.cwd;\n\n // Merge environment variables\n const mergedEnv = {\n ...process.env,\n ...env,\n };\n\n ctx.platform.logger.info('Executing shell command', {\n command: command.slice(0, 200),\n cwd,\n timeout,\n });\n\n try {\n // detached: true puts the subprocess in its own process group. On timeout we kill\n // the entire group (process.kill(-pid, 'SIGKILL')), which takes down both the shell\n // and any children it spawned. Without this, SIGTERM reaches only the shell; child\n // processes (e.g. `sleep N`) inherit the pipe write-end and block await indefinitely.\n const proc = execaCommand(command, {\n cwd,\n env: mergedEnv,\n shell: true,\n stdio: 'pipe',\n reject: false, // We handle exit codes ourselves\n detached: true,\n });\n\n // Stream stdout/stderr line-by-line in real-time.\n // We consume the streams ourselves via 'data' events for live log streaming.\n // Because attaching a 'data' listener drains the stream, execa's result.stdout\n // will be empty — we must reconstruct the full output from collected chunks.\n let lineNo = 0;\n let stdoutBuf = '';\n let stderrBuf = '';\n let stdoutFull = '';\n let stderrFull = '';\n\n const emitLine = (stream: 'stdout' | 'stderr', line: string) => {\n lineNo++;\n void ctx.api.events.emit('log.line', { stream, line, lineNo, level: stream === 'stderr' ? 'error' : 'info' });\n };\n\n proc.stdout?.on('data', (chunk: Buffer) => {\n const text = chunk.toString();\n stdoutFull += text;\n stdoutBuf += text;\n const lines = stdoutBuf.split('\\n');\n stdoutBuf = lines.pop() ?? '';\n for (const line of lines) {emitLine('stdout', line);}\n });\n\n proc.stderr?.on('data', (chunk: Buffer) => {\n const text = chunk.toString();\n stderrFull += text;\n stderrBuf += text;\n const lines = stderrBuf.split('\\n');\n stderrBuf = lines.pop() ?? '';\n for (const line of lines) {emitLine('stderr', line);}\n });\n\n // Self-managed timeout: kill the entire process group so orphaned children\n // release the pipe write-end and await proc resolves immediately.\n let timedOut = false;\n const killTimer = setTimeout(() => {\n timedOut = true;\n const pid = proc.pid;\n if (pid !== undefined) {\n try {\n process.kill(-pid, 'SIGKILL');\n } catch {\n // process already exited\n }\n }\n }, timeout);\n\n const result = await proc;\n clearTimeout(killTimer);\n\n // Flush remaining buffered content\n if (stdoutBuf) {emitLine('stdout', stdoutBuf);}\n if (stderrBuf) {emitLine('stderr', stderrBuf);}\n\n if (timedOut) {\n throw new Error(`Shell command timed out after ${timeout}ms`);\n }\n\n const output: ShellOutput = {\n // Use our accumulated buffers — result.stdout/stderr are empty because\n // we consumed the streams via 'data' listeners above.\n stdout: stdoutFull || result.stdout,\n stderr: stderrFull || result.stderr,\n exitCode: result.exitCode ?? 0,\n ok: (result.exitCode ?? 0) === 0,\n };\n\n if (output.ok) {\n ctx.platform.logger.info('Shell command completed successfully', {\n exitCode: output.exitCode,\n stdoutLines: output.stdout.split('\\n').length,\n });\n } else {\n ctx.platform.logger.warn('Shell command failed', {\n exitCode: output.exitCode,\n stderrLines: output.stderr.split('\\n').length,\n });\n\n if (throwOnError) {\n throw new Error(`Shell command failed with exit code ${output.exitCode}: ${output.stderr.slice(0, 500)}`);\n }\n }\n\n return mergeJsonOutputs(output, (msg) => ctx.platform.logger.warn(`[shell] ${msg}`));\n } catch (error) {\n // Handle timeout\n if (error && typeof error === 'object' && 'timedOut' in error && error.timedOut) {\n throw new Error(`Shell command timed out after ${timeout}ms`);\n }\n\n // Handle execution error\n if (error && typeof error === 'object' && 'exitCode' in error) {\n const execError = error as { exitCode?: number; stdout?: string; stderr?: string };\n const output: ShellOutput = {\n stdout: execError.stdout ?? '',\n stderr: execError.stderr ?? '',\n exitCode: execError.exitCode ?? 1,\n ok: false,\n };\n\n ctx.platform.logger.error('Shell command execution failed', undefined, {\n exitCode: output.exitCode,\n stderr: output.stderr.slice(0, 500),\n });\n\n if (!throwOnError) {\n return { ...output };\n }\n }\n\n throw error;\n }\n}\n\n// Export handler in format expected by ExecutionBackend\nexport default {\n execute: shellHandler,\n};\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kb-labs/workflow-steps",
|
|
3
|
+
"description": "Built-in workflow step handlers (shell, approval, gate)",
|
|
4
|
+
"version": "2.96.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./shell": {
|
|
14
|
+
"types": "./dist/shell.d.ts",
|
|
15
|
+
"import": "./dist/shell.js"
|
|
16
|
+
},
|
|
17
|
+
"./approval": {
|
|
18
|
+
"types": "./dist/approval.d.ts",
|
|
19
|
+
"import": "./dist/approval.js"
|
|
20
|
+
},
|
|
21
|
+
"./gate": {
|
|
22
|
+
"types": "./dist/gate.d.ts",
|
|
23
|
+
"import": "./dist/gate.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"execa": "^9.5.3",
|
|
32
|
+
"@kb-labs/plugin-contracts": "2.96.0",
|
|
33
|
+
"@kb-labs/workflow-contracts": "2.96.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^24.3.3",
|
|
37
|
+
"rimraf": "^6.0.1",
|
|
38
|
+
"tsup": "^8.5.0",
|
|
39
|
+
"typescript": "^5.6.3",
|
|
40
|
+
"vitest": "^3.2.6",
|
|
41
|
+
"@kb-labs/devkit": "2.96.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20.0.0",
|
|
45
|
+
"pnpm": ">=9.0.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "pnpm clean && tsup --config tsup.config.ts",
|
|
49
|
+
"clean": "rimraf dist",
|
|
50
|
+
"dev": "tsup --config tsup.config.ts --watch",
|
|
51
|
+
"lint": "eslint src --ext .ts",
|
|
52
|
+
"lint:fix": "eslint . --fix",
|
|
53
|
+
"test": "vitest run --passWithNoTests",
|
|
54
|
+
"test:watch": "vitest",
|
|
55
|
+
"type-check": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|