LabHit Docs

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

  1. Keep extensions focused. One extension, one task. Don't build a monolith.

  2. Use metadata outputs sparingly. Store large data in workspace files, pass references via metadata.

  3. Handle errors gracefully. Return ExecutionStatus::Failure with descriptive metadata rather than panicking.

  4. Declare minimal capabilities. Only request the permissions you need. Fewer capabilities means faster review and higher trust.

  5. Log to stderr. Use eprintln!() for debug output. The engine captures stderr as stage logs. Stdout is reserved for the StepOutput JSON.

  6. Test with labhit extension test. This validates your extension against the real runtime without needing a full pipeline.

  7. Version your extension. Follow semver. Breaking changes to inputs/outputs require a major version bump.