Skip to content

Exec: validation & hosted scripts

Chapter 6 introduced the exec statement as the escape hatch. The statement runs a shell command. The program must import std.script. The operator must also put the command in the WHIPPLESCRIPT_EXEC_ALLOW allow-list. The statement has a success branch and a failure branch. Chapter 6 also gave an honest limit: the dev profile has no sandbox, and a grant is a deliberate decision to trust. This chapter completes the construct on the two axes that make the construct sufficient for production: typed results and pinned execution.

Deterministic validation: the exec … -> Schema statement

A coerce statement asks a model for a judgment. Many checks need the opposite. A validator gives an answer that is a function of its input. Format detection, schema lint, and checksum verification are examples. In each of these conditions, the same artifact must always give the same result. The exec "<command>" -> Schema statement gives this behavior:

use std.script

workflow DeterministicValidation

output result ValidationReport
failure error ValidationFailed

class Artifact {
  path string
  expectedScript string
}

class ScriptCheck {
  isExpectedScript bool
  detail string
}

class ValidationReport {
  path string
  detail string
}

class ValidationFailed {
  reason string
}

table artifacts as Artifact [
  {
    path "target/dogfood/sample.txt"
    expectedScript "Latin"
  }
]

rule validate_artifact_script
  when Artifact as artifact
=> {
  exec "check-script --json" -> ScriptCheck as check

  after check succeeds as result {
    done artifact
    complete result {
      path artifact.path
      detail result.detail
    }
  }

  after check fails as failure {
    fail error {
      reason failure.reason
    }
  }
}

The command prints JSON on stdout. The arrow admits that output only when the output decodes against the schema. The result binding is then a typed ScriptCheck value. Thus result.isExpectedScript branches as each typed field does. A validator that prints incorrect data gives a typed failure of the effect. The runtime routes the failure to the after check fails branch. Incorrect data never enters your facts without a report.

This is the division of the work with chapter 8. Use a coerce statement for a judgment. Use an exec -> Schema statement for a deterministic answer. Use a bare exec statement for plumbing. Plumbing is a command that runs for its side effect, and the exit code checks the command.

The hosted profile: capabilities and not command strings

The dev profile trusts the allow-list of the operator. The dev profile also puts values into a command string. For each use outside development, use the hosted profile. The hosted profile removes the two liberties. The profile rejects a raw command string fully. The source gives the name of a script capability instead. The operator pins the capability. The source passes typed input on stdin:

use std.script

workflow Hosted

output result Done
failure error Failed

class Request {
  name string
}

class Reply {
  message string
}

class Done {
  note string
}

class Failed {
  reason string
}

table requests as Request [
  {
    name "ops"
  }
]

rule call
  when Request as request
=> {
  exec greet with request -> Reply as call

  after call succeeds as reply {
    complete result {
      note reply.message
    }
  }

  after call fails as f {
    fail error {
      reason f.reason
    }
  }
}

The exec greet with request -> Reply statement sends the request fact as JSON on the stdin of the script. The statement admits the stdout of the script through the Reply schema. Thus the two directions are typed. The definition of greet is outside the workspace, in a manifest that the operator supplies:

{
  "greet": {
    "argv": ["bash", "greet.sh"],
    "sha256": "435941dbff79…",
    "env": { "GREET_TOKEN": "env:GREET_TOKEN" }
  }
}

Run the workflow with the --exec-profile hosted --script-manifest <path> flags. As an alternative, use the WHIPPLESCRIPT_EXEC_PROFILE variable and the WHIPPLESCRIPT_SCRIPT_MANIFEST variable. The worker verifies the bytes of the script against the pinned sha256 value. The worker stages a verified copy. The worker then starts the argv list directly. There is no shell. No source text goes into a command line. Each secret is a reference to an environment variable, and the worker resolves the reference at the start of the process.

The pin has an effect. Change one byte of the script after the pin operation:

instance ins_… failed
reason: script capability `greet` hash mismatch: expected 435941db…, got f45dd87b…

The mismatch fails before the start of the process. The evidence of the failure has the expected hash and the actual hash. Thus a change in the supply chain is a loud failure that you can attribute. The change is not a silent difference in the execution.

How to select a profile

The two profiles are one construct with two positions on trust. Use the dev profile statement exec "cmd" for a workspace that you already trust. Quick validators, build steps, and small connections are examples. The import and the allow-list are the gates. The profile is honest: it has no sandbox. Use the hosted profile statement exec <capability> with <input> for each operation that an operator must be able to audit later. The workflow states the item that it needs, by name and by type. The operator owns the item that runs, byte for byte. The two profiles settle through the same lifecycle of an effect from chapter 5. Thus a workflow can start on the dev profile and move to the hosted profile. The shape of its rules does not change. Change the command string to a capability name, and move the definition into the manifest.

Where next

Chapter 20 gives files. The chapter gives the file store declaration, the read, write, import, and export effects, and the policy boundary that is read-only by default. The turn grants in chapter 11 pointed at this boundary.