Extension Developer Guide
Build, test, and publish WASM extensions for the LabHit CI/CD engine.
How Extensions Work
LabHit extensions are WebAssembly modules that communicate with the
engine via a JSON protocol over stdin/stdout. The engine sends a
StepInput as JSON to the extension's stdin, and the
extension writes a StepOutput as JSON to stdout. This
design is simple, debuggable, and language-agnostic.
Engine Extension (WASM)
│ │
├── StepInput JSON ──→ stdin ───→│
│ ├── Execute logic
│ ├── Read files, secrets
│◀── stdout ──→ StepOutput JSON ─┤
│ │
Extensions run inside a sandboxed WASM runtime with deny-by-default permissions. They cannot access the network, filesystem, or secrets unless explicitly granted in the extension manifest.
Quick Start
1. Create a new extension
labhit extension init my-scanner
cd my-scanner
This creates:
my-scanner/
├── Cargo.toml # Rust project with labhit-pdk dependency
├── extension.yaml # Extension manifest (capabilities, metadata)
└── src/
└── lib.rs # Extension entry point
2. Write the extension
Edit src/lib.rs:
use labhit_pdk::prelude::*;
use std::io::{self, Read, Write};
fn main() {
// Read StepInput from stdin
let mut input_json = String::new();
io::stdin().read_to_string(&mut input_json).unwrap();
let input: StepInput = serde_json::from_str(&input_json).unwrap();
// Your extension logic
let stage = &input.context.stage_name;
eprintln!("[my-scanner] scanning workspace for {}", stage);
// Find files to scan
let file_count = 42; // Replace with actual scanning logic
// Return StepOutput to stdout
let output = StepOutput {
status: ExecutionStatus::Success,
artifacts: vec![],
metadata: vec![
ConfigEntry {
key: "files_scanned".into(),
value: file_count.to_string(),
},
],
};
let output_json = serde_json::to_string(&output).unwrap();
io::stdout().write_all(output_json.as_bytes()).unwrap();
}
3. Build for WASM
labhit extension build
# Or manually:
cargo build --target wasm32-wasip1 --release
The compiled module is at
target/wasm32-wasip1/release/my_scanner.wasm.
4. Test locally
labhit extension test
This runs the extension in the local WASM runtime with a sample
StepInput.
5. Use in a pipeline
Copy the .wasm file to
.labhit/extensions/scan/my-scanner.wasm, then reference it
in your pipeline:
engine: "1"
pipeline:
name: build-and-scan
stages:
checkout:
run: git clone --depth 1 $REPO .
scan:
after: [checkout]
use: scan/my-scanner
with:
severity: high
Extension Manifest
Every extension requires an extension.yaml file:
id: "scan/my-scanner"
name: "My Scanner"
version: "0.1.0"
description: "Scans workspace files for issues"
author: "Your Name"
license: "Apache-2.0"
capabilities:
- type: filesystem
paths: ["."]
- type: environment
keys: ["CI", "LABHIT_STAGE_NAME"]
Manifest Fields
| Field | Required | Description |
|---|---|---|
id |
Yes | Extension identifier in category/name format |
name |
Yes | Human-readable name |
version |
Yes | Semver version string |
description |
Yes | One-line description |
author |
Yes | Author name or organization |
license |
Yes | SPDX license identifier |
capabilities |
No | List of requested permissions |
Capabilities
Extensions declare what they need access to. The engine enforces these at runtime.
| Type | Description | Example |
|---|---|---|
filesystem |
Read/write paths relative to workspace | paths: [".", "output/"] |
network |
Allowed network destinations | allow: ["api.github.com"] |
secrets |
Secret names the extension may read | keys: ["GITHUB_TOKEN"] |
environment |
Environment variable access | keys: ["CI", "BRANCH"] |
Undeclared capabilities are denied. An extension that tries to access a secret it didn't declare in its manifest will receive an error.
PDK Types Reference
StepInput
Sent to the extension via stdin as JSON:
{
"context": {
"pipeline_id": "pipe-1",
"pipeline_name": "build-and-deploy",
"stage_name": "scan",
"run_id": "019...",
"commit_sha": "abc123def",
"branch": "main",
"workspace_path": "/workspace"
},
"config": [
{ "key": "severity", "value": "high" }
],
"inputs": []
}
| Field | Type | Description |
|---|---|---|
context.pipeline_id |
string | Pipeline definition identifier |
context.pipeline_name |
string | Human-readable pipeline name |
context.stage_name |
string | Name of the stage invoking this extension |
context.run_id |
string | Unique run identifier (UUID v7) |
context.commit_sha |
string | Git commit SHA that triggered the run |
context.branch |
string | Git branch name |
context.workspace_path |
string | Absolute path to shared workspace |
config |
array | Key-value pairs from the pipeline with: block |
inputs |
array | Artifact references from upstream stages |
StepOutput
Written by the extension to stdout as JSON:
{
"status": "success",
"artifacts": [
{
"name": "scan-report",
"path": "reports/scan.json",
"content_hash": "sha256:abc..."
}
],
"metadata": [
{ "key": "vulnerabilities", "value": "0" }
]
}
| Field | Type | Description |
|---|---|---|
status |
enum | success, failure, skipped, or
warning |
artifacts |
array | Files produced by this step |
metadata |
array | Key-value outputs for downstream stages |
ExecutionStatus
| Value | Meaning | Pipeline effect |
|---|---|---|
success |
Step completed normally | Continue to next stages |
failure |
Step failed | Mark stage as Failed, skip dependents |
skipped |
Step was skipped (e.g., condition not met) | Mark as Skipped |
warning |
Completed with non-fatal issues | Continue (treated as success) |
Extension Categories
Extensions follow the category/name naming
convention:
| Category | Purpose | Examples |
|---|---|---|
source |
Fetch code or data | source/git, source/s3 |
build |
Compile, package, containerize | build/container, build/npm |
test |
Run test suites | test/pytest, test/jest |
scan |
Security and quality scanning | scan/trivy, scan/eslint |
deploy |
Push to deployment targets | deploy/kubernetes, deploy/aws |
notify |
Send notifications | notify/slack, notify/email |
gate |
Approval and policy gates | gate/manual, gate/jira |
CLI Commands
| Command | Description |
|---|---|
labhit extension init <name> |
Scaffold a new Rust extension project |
labhit extension build |
Compile to wasm32-wasip1 |
labhit extension test |
Run locally in the WASM runtime |
labhit extension list |
List installed extensions |
Example: Hello World Extension
A minimal extension that returns a greeting:
use labhit_pdk::prelude::*;
use std::io::{self, Read, Write};
fn main() {
let mut input_json = String::new();
io::stdin().read_to_string(&mut input_json).unwrap();
let input: StepInput = serde_json::from_str(&input_json).unwrap();
let name = input
.config
.iter()
.find(|c| c.key == "name")
.map(|c| c.value.as_str())
.unwrap_or("world");
let output = StepOutput {
status: ExecutionStatus::Success,
artifacts: vec![],
metadata: vec![ConfigEntry {
key: "greeting".into(),
value: format!("Hello, {}!", name),
}],
};
serde_json::to_writer(io::stdout(), &output).unwrap();
}
Pipeline usage:
stages:
greet:
use: test/hello
with:
name: LabHit
Security Model
Extensions run with strict sandboxing:
- WASM isolation: Each extension runs in its own WASM instance with no shared memory
- Fuel metering: CPU usage is bounded by a configurable fuel limit
- Deny-by-default: No network, filesystem, or secret access unless declared
- Capability enforcement: The runtime validates manifest declarations before granting access
- No ambient authority: Extensions cannot escalate privileges at runtime
Best Practices
Keep extensions focused. One extension, one task. Don't build a monolith.
Use metadata outputs sparingly. Store large data in workspace files, pass references via metadata.
Handle errors gracefully. Return
ExecutionStatus::Failurewith descriptive metadata rather than panicking.Declare minimal capabilities. Only request the permissions you need. Fewer capabilities means faster review and higher trust.
Log to stderr. Use
eprintln!()for debug output. The engine captures stderr as stage logs. Stdout is reserved for theStepOutputJSON.Test with
labhit extension test. This validates your extension against the real runtime without needing a full pipeline.Version your extension. Follow semver. Breaking changes to inputs/outputs require a major version bump.