[engine] Expose runtime-configurable variables to checks

Makes a new `ctx.vars.get()` field available to checks that is used to
retrieve optional runtime-configurable values passed into shac via
`--var name=value` command-line flags.

In order to be set at runtime, a var must be declared beforehand in
shac.textproto with an optional default value. Centralizating the list
of allowed variables differs from the strategies taken by lucicfg and
GN, which both allow any file to declare arbitrary runtime-configurable
variables. However, that approach makes it much harder to discover the
list of allowed variables, and makes it much easier to add new
runtime-configurable variables.

A proliferation of runtime-configurable variables is not desired for
shac because it would open the door to divergence between local
workflows and CI, e.g. if CI sets many variables to non-default values
then it would become difficult to accurately reproduce CI results
locally. Therefore, vars should only be used when absolutely necessary
and only for passing through opaque parameters rather than tweaking
behavior. The most immediate use case is for passing in the path to a
repository's build directory into shac so shac can run executables and
read artifacts from the build directory.

Bug: 82386
Change-Id: I18cacfc8d9800e4d784c108389e697b5d6b12bc1
Reviewed-on: https://fuchsia-review.googlesource.com/c/shac-project/shac/+/915494
Reviewed-by: Anthony Fandrianto <atyfto@google.com>
Fuchsia-Auto-Submit: Oliver Newman <olivernewman@google.com>
Commit-Queue: Auto-Submit <auto-submit@fuchsia-infra.iam.gserviceaccount.com>
diff --git a/doc/stdlib.md b/doc/stdlib.md
index 60c9dce..21aa552 100644
--- a/doc/stdlib.md
+++ b/doc/stdlib.md
@@ -114,6 +114,7 @@
 - platform
 - re
 - scm
+- vars
 
 ## ctx.emit
 
@@ -413,7 +414,7 @@
 
 ### Arguments
 
-* **pattern**: Pegexp to run. The syntax as described at https://golang.org/s/re2syntax.
+* **pattern**: Regexp to run. The syntax as described at https://golang.org/s/re2syntax.
 * **string**: String to run the regexp on.
 
 ### Returns
@@ -452,7 +453,7 @@
 ### Example
 
 ```python
-def new_todos(cb):
+def new_todos(ctx):
   # Prints only the TODO that were added compared to upstream.
   for path, meta in ctx.scm.affected_files().items():
     for num, line in meta.new_lines():
@@ -481,7 +482,7 @@
 ### Example
 
 ```python
-def all_todos(cb):
+def all_todos(ctx):
   for path, meta in ctx.scm.all_files().items():
     for num, line in meta.new_lines():
       m = ctx.re.match("TODO\(([^)]+)\).*", line)
@@ -500,6 +501,46 @@
 A map of {path: struct()} where the struct has a string field action and a
 function new_lines().
 
+## ctx.vars
+
+ctx.vars provides access to runtime-configurable variables.
+
+Fields:
+
+- get
+
+## ctx.vars.get
+
+Returns the value of a runtime-configurable variable.
+
+The value may be specified at runtime by using the `--var name=value` flag
+when running shac. In order to be set at runtime, a variable must be
+registered in shac.textproto.
+
+If not set via the command line, the value will be the default declared in
+shac.textproto.
+
+Raises an error if the requested variable is not registered in the project's
+shac.textproto config file.
+
+### Example
+
+```python
+def cb(ctx):
+  build_dir = ctx.vars.get("build_directory")
+  ctx.os.exec([build_dir + "/compiled_tool"]).wait()
+
+shac.register_check(cb)
+```
+
+### Arguments
+
+* **name**: The name of the variable.
+
+### Returns
+
+A string corresponding to the current value of the variable.
+
 ## dir
 
 Starlark builtin that returns all the attributes of an object.
diff --git a/doc/stdlib.star b/doc/stdlib.star
index 9a1dead..c6e5be1 100644
--- a/doc/stdlib.star
+++ b/doc/stdlib.star
@@ -361,7 +361,7 @@
       ```
 
     Args:
-      pattern: Pegexp to run. The syntax as described at
+      pattern: Regexp to run. The syntax as described at
         https://golang.org/s/re2syntax.
       string: String to run the regexp on.
 
@@ -385,7 +385,7 @@
 
     Example:
       ```python
-      def new_todos(cb):
+      def new_todos(ctx):
         # Prints only the TODO that were added compared to upstream.
         for path, meta in ctx.scm.affected_files().items():
           for num, line in meta.new_lines():
@@ -413,7 +413,7 @@
 
     Example:
       ```python
-      def all_todos(cb):
+      def all_todos(ctx):
         for path, meta in ctx.scm.all_files().items():
           for num, line in meta.new_lines():
             m = ctx.re.match("TODO\\(([^)]+)\\).*", line)
@@ -433,6 +433,36 @@
     """
     pass
 
+def _ctx_vars_get(name):
+    """Returns the value of a runtime-configurable variable.
+
+    The value may be specified at runtime by using the `--var name=value` flag
+    when running shac. In order to be set at runtime, a variable must be
+    registered in shac.textproto.
+
+    If not set via the command line, the value will be the default declared in
+    shac.textproto.
+
+    Raises an error if the requested variable is not registered in the project's
+    shac.textproto config file.
+
+    Example:
+      ```python
+      def cb(ctx):
+        build_dir = ctx.vars.get("build_directory")
+        ctx.os.exec([build_dir + "/compiled_tool"]).wait()
+
+      shac.register_check(cb)
+      ```
+
+    Args:
+      name: The name of the variable.
+
+    Returns:
+      A string corresponding to the current value of the variable.
+    """
+    pass
+
 # ctx is the object passed to shac.register_check(...) callback.
 ctx = struct(
     # ctx.emit is the object that exposes the API to emit results for checks.
@@ -477,6 +507,10 @@
         affected_files = _ctx_scm_affected_files,
         all_files = _ctx_scm_all_files,
     ),
+    # ctx.vars provides access to runtime-configurable variables.
+    vars = struct(
+        get = _ctx_vars_get,
+    ),
 )
 
 def dir(x):
diff --git a/internal/cli/base.go b/internal/cli/base.go
index e5981aa..97f7e5a 100644
--- a/internal/cli/base.go
+++ b/internal/cli/base.go
@@ -26,12 +26,15 @@
 	cwd       string
 	allFiles  bool
 	noRecurse bool
+	vars      stringMapFlag
 }
 
 func (c *commandBase) SetFlags(f *flag.FlagSet) {
 	f.StringVarP(&c.cwd, "cwd", "C", ".", "directory in which to run shac")
 	f.BoolVar(&c.allFiles, "all", false, "checks all the files instead of guess the upstream to diff against")
 	f.BoolVar(&c.noRecurse, "no-recurse", false, "do not look for shac.star files recursively")
+	c.vars = stringMapFlag{}
+	f.Var(&c.vars, "var", "runtime variables to set, of the form key=value")
 
 	// TODO(olivernewman): Delete this flag after it's no longer used.
 	f.StringVar(&c.root, "root", ".", "path to the root of the tree to analyse")
@@ -56,5 +59,6 @@
 		AllFiles: c.allFiles,
 		Files:    files,
 		Recurse:  !c.noRecurse,
+		Vars:     c.vars,
 	}, nil
 }
diff --git a/internal/cli/string_map_flag.go b/internal/cli/string_map_flag.go
new file mode 100644
index 0000000..4b116ab
--- /dev/null
+++ b/internal/cli/string_map_flag.go
@@ -0,0 +1,54 @@
+// Copyright 2023 The Shac Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cli
+
+import (
+	"encoding/json"
+	"errors"
+	"strings"
+
+	flag "github.com/spf13/pflag"
+)
+
+type stringMapFlag map[string]string
+
+var _ flag.Value = (*stringMapFlag)(nil)
+
+func (v stringMapFlag) String() string {
+	if v == nil {
+		return "{}"
+	}
+	b, err := json.Marshal(v)
+	if err != nil {
+		panic(err)
+	}
+	return string(b)
+}
+
+func (v stringMapFlag) Set(s string) error {
+	name, value, ok := strings.Cut(s, "=")
+	if !ok || name == "" {
+		return errors.New("must be of the form key=value")
+	}
+	if _, ok := v[name]; ok {
+		return errors.New("duplicate key")
+	}
+	v[name] = value
+	return nil
+}
+
+func (v stringMapFlag) Type() string {
+	return "vars"
+}
diff --git a/internal/cli/string_map_flag_test.go b/internal/cli/string_map_flag_test.go
new file mode 100644
index 0000000..0204cf0
--- /dev/null
+++ b/internal/cli/string_map_flag_test.go
@@ -0,0 +1,94 @@
+// Copyright 2023 The Shac Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cli
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	flag "github.com/spf13/pflag"
+)
+
+func TestStringMapFlag(t *testing.T) {
+	t.Parallel()
+	data := []struct {
+		name    string
+		args    []string
+		want    stringMapFlag
+		wantErr string
+	}{
+		{
+			name: "empty",
+			args: nil,
+			want: nil,
+		},
+		{
+			name: "one",
+			args: []string{"--kv", "x=y"},
+			want: stringMapFlag{"x": "y"},
+		},
+		{
+			name: "two",
+			args: []string{"--kv", "x=y", "--kv", "a=b"},
+			want: stringMapFlag{"x": "y", "a": "b"},
+		},
+		{
+			name: "empty string value",
+			args: []string{"--kv", "x="},
+			want: stringMapFlag{"x": ""},
+		},
+		{
+			name:    "empty string key",
+			args:    []string{"--kv", "=y"},
+			wantErr: `invalid argument "=y" for "--kv" flag: must be of the form key=value`,
+		},
+		{
+			name:    "duplicate",
+			args:    []string{"--kv", "x=y", "--kv", "x=z"},
+			wantErr: `invalid argument "x=z" for "--kv" flag: duplicate key`,
+		},
+		{
+			name:    "malformed",
+			args:    []string{"--kv", "xy"},
+			wantErr: `invalid argument "xy" for "--kv" flag: must be of the form key=value`,
+		},
+	}
+	for i := range data {
+		i := i
+		t.Run(data[i].name, func(t *testing.T) {
+			m := stringMapFlag{}
+			f := flag.NewFlagSet("test", flag.ContinueOnError)
+			f.Var(&m, "kv", "")
+
+			err := f.Parse(data[i].args)
+			if err != nil {
+				if data[i].wantErr == "" {
+					t.Fatal(err)
+				}
+				if diff := cmp.Diff(data[i].wantErr, err.Error()); diff != "" {
+					t.Errorf("Unexpected error: %s", diff)
+				}
+			} else {
+				if data[i].wantErr != "" {
+					t.Fatalf("Wanted error %q, got nil", data[i].wantErr)
+				}
+				if diff := cmp.Diff(data[i].want, m, cmpopts.EquateEmpty()); diff != "" {
+					t.Errorf("unexpected diff:\n%s", diff)
+				}
+			}
+		})
+	}
+}
diff --git a/internal/engine/config.go b/internal/engine/config.go
index db7fe89..d90956d 100644
--- a/internal/engine/config.go
+++ b/internal/engine/config.go
@@ -117,6 +117,11 @@
 			return fmt.Errorf("vendor_path %s is not clean", doc.VendorPath)
 		}
 	}
+	for _, v := range doc.Vars {
+		if v.Name == "" {
+			return fmt.Errorf("vars cannot have empty names")
+		}
+	}
 	return nil
 }
 
diff --git a/internal/engine/config_test.go b/internal/engine/config_test.go
index 86a2a5b..efedfdb 100644
--- a/internal/engine/config_test.go
+++ b/internal/engine/config_test.go
@@ -256,6 +256,15 @@
 			"",
 		},
 		{
+			"vars: [\n" +
+				"{\n" +
+				"name: \"\"\n" +
+				"default: \"foo\"\n" +
+				"}\n" +
+				"]\n",
+			"vars cannot have empty names",
+		},
+		{
 			"min_shac_version: \"1000\"\n",
 			func() string {
 				return fmt.Sprintf(
diff --git a/internal/engine/run.go b/internal/engine/run.go
index aec0511..cd4ec1f 100644
--- a/internal/engine/run.go
+++ b/internal/engine/run.go
@@ -175,6 +175,8 @@
 	Recurse bool
 	// Filter controls which checks run.
 	Filter CheckFilter
+	// Vars contains the user-specified runtime variables and their values.
+	Vars map[string]string
 
 	// main source file to run. Defaults to shac.star. Only used in unit tests.
 	main string
@@ -243,6 +245,17 @@
 		scm = &cachingSCM{scm: scm}
 	}
 
+	vars := make(map[string]string)
+	for _, v := range doc.Vars {
+		vars[v.Name] = v.Default
+	}
+	for name, value := range o.Vars {
+		if _, ok := vars[name]; !ok {
+			return fmt.Errorf("var not declared in %s: %s", config, name)
+		}
+		vars[name] = value
+	}
+
 	tmpdir, err := os.MkdirTemp("", "shac")
 	if err != nil {
 		return err
@@ -252,7 +265,7 @@
 	if err != nil {
 		return err
 	}
-	err = runInner(ctx, root, tmpdir, main, doc.AllowNetwork, doc.WritableRoot, o, scm, packages)
+	err = runInner(ctx, root, tmpdir, main, doc.AllowNetwork, doc.WritableRoot, o, scm, packages, vars)
 	if err2 := os.RemoveAll(tmpdir); err == nil {
 		err = err2
 	}
@@ -314,7 +327,7 @@
 	return res, nil
 }
 
-func runInner(ctx context.Context, root, tmpdir, main string, allowNetwork, writableRoot bool, o *Options, scm scmCheckout, packages map[string]fs.FS) error {
+func runInner(ctx context.Context, root, tmpdir, main string, allowNetwork, writableRoot bool, o *Options, scm scmCheckout, packages map[string]fs.FS, vars map[string]string) error {
 	sb, err := sandbox.New(tmpdir)
 	if err != nil {
 		return err
@@ -346,6 +359,7 @@
 			subdir:       subdir,
 			tmpdir:       filepath.Join(tmpdir, strconv.Itoa(idx)),
 			writableRoot: writableRoot,
+			vars:         vars,
 		}
 	}
 	var shacStates []*shacState
@@ -491,6 +505,8 @@
 	// root is the root for the root shac.star that was executed. Native path
 	// style.
 	root string
+	// vars is the map of runtime variables and their values.
+	vars map[string]string
 	// subdir is the relative directory in which this shac.star is located.
 	// Only set when Options.Recurse is set to true. POSIX path style.
 	subdir string
@@ -571,7 +587,7 @@
 
 // bufferAllChecks adds all the checks to the channel for execution.
 func (s *shacState) bufferAllChecks(ctx context.Context, ch chan<- func() error) {
-	args := starlark.Tuple{getCtx(path.Join(s.root, s.subdir))}
+	args := starlark.Tuple{getCtx(path.Join(s.root, s.subdir), s.vars)}
 	args.Freeze()
 	for i := range s.checks {
 		if s.filter != nil && !s.filter(s.checks[i]) {
diff --git a/internal/engine/run_test.go b/internal/engine/run_test.go
index 4186e00..16997ba 100644
--- a/internal/engine/run_test.go
+++ b/internal/engine/run_test.go
@@ -117,6 +117,15 @@
 			},
 			fmt.Sprintf("not a directory: %s", filepath.Join(scratchDir, "foo.txt")),
 		},
+		{
+			"invalid var",
+			Options{
+				Vars: map[string]string{
+					"unknown_var": "",
+				},
+			},
+			"var not declared in shac.textproto: unknown_var",
+		},
 	}
 	for i := range data {
 		i := i
@@ -463,6 +472,67 @@
 	})
 }
 
+func TestRun_Vars(t *testing.T) {
+	t.Parallel()
+
+	data := []struct {
+		name       string
+		configVars map[string]string
+		flagVars   map[string]string
+		want       string
+	}{
+		{
+			name:       "default",
+			configVars: map[string]string{"foo": "default_foo"},
+			want:       "default_foo",
+		},
+		{
+			name:       "overridden",
+			configVars: map[string]string{"foo": "default_foo"},
+			flagVars:   map[string]string{"foo": "overridden_foo"},
+			want:       "overridden_foo",
+		},
+	}
+	for i := range data {
+		i := i
+		t.Run(data[i].name, func(t *testing.T) {
+			t.Parallel()
+			root := t.TempDir()
+			main := "ctx-var-value.star"
+			copyFile(t, root, filepath.Join("testdata", main))
+			r := reportPrint{reportNoPrint: reportNoPrint{t: t}}
+			o := Options{
+				Report: &r,
+				Dir:    root,
+				Vars:   data[i].flagVars,
+				main:   main,
+			}
+
+			config := &Document{}
+			for name, def := range data[i].configVars {
+				config.Vars = append(config.Vars, &Var{
+					Name:        name,
+					Description: "a variable",
+					Default:     def,
+				})
+			}
+			b, err := prototext.Marshal(config)
+			if err != nil {
+				t.Fatal(err)
+			}
+			writeFileBytes(t, root, "shac.textproto", b, 0o600)
+
+			if err = Run(context.Background(), &o); err != nil {
+				t.Fatal(err)
+			}
+			want := fmt.Sprintf("[//ctx-var-value.star:16] %s\n", data[i].want)
+			if diff := cmp.Diff(want, r.b.String()); diff != "" {
+				t.Fatalf("mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
 func TestRun_SCM_Raw(t *testing.T) {
 	t.Parallel()
 	root := t.TempDir()
@@ -1424,6 +1494,16 @@
 			"  //ctx-scm-all_files-kwarg.star:16:22: in cb\n",
 		},
 		{
+			"ctx-vars-empty.star",
+			"ctx.vars.get: for parameter \"name\": must not be empty",
+			"  //ctx-vars-empty.star:16:17: in cb\n",
+		},
+		{
+			"ctx-vars-invalid.star",
+			"ctx.vars.get: unknown variable \"invalid_var\"",
+			"  //ctx-vars-invalid.star:16:17: in cb\n",
+		},
+		{
 			"empty.star",
 			"did you forget to call shac.register_check?",
 			"",
@@ -1902,7 +1982,7 @@
 		},
 		{
 			"dir-ctx.star",
-			"[//dir-ctx.star:16] [\"emit\", \"io\", \"os\", \"platform\", \"re\", \"scm\"]\n",
+			"[//dir-ctx.star:16] [\"emit\", \"io\", \"os\", \"platform\", \"re\", \"scm\", \"vars\"]\n",
 		},
 		{
 			"dir-shac.star",
diff --git a/internal/engine/runtime_ctx.go b/internal/engine/runtime_ctx.go
index ae0b0a8..0cbf6ca 100644
--- a/internal/engine/runtime_ctx.go
+++ b/internal/engine/runtime_ctx.go
@@ -23,7 +23,11 @@
 // getCtx returns the ctx object to pass to a registered check callback.
 //
 // Make sure to update //doc/stdlib.star whenever this function is modified.
-func getCtx(root string) starlark.Value {
+func getCtx(root string, vars map[string]string) starlark.Value {
+	starlarkVars := starlark.NewDict(len(vars))
+	for k, v := range vars {
+		starlarkVars.SetKey(starlark.String(k), starlark.String(v))
+	}
 	return toValue("ctx", starlark.StringDict{
 		// Implemented in runtime_ctx_emit.go
 		"emit": toValue("ctx.emit", starlark.StringDict{
@@ -53,5 +57,9 @@
 			"affected_files": newBuiltin("ctx.scm.affected_files", ctxScmAffectedFiles),
 			"all_files":      newBuiltin("ctx.scm.all_files", ctxScmAllFiles),
 		}),
+		// Implemented in runtime_ctx_vars.go
+		"vars": toValue("ctx.vars", starlark.StringDict{
+			"get": newBuiltin("ctx.vars.get", ctxVarsGet),
+		}),
 	})
 }
diff --git a/internal/engine/runtime_ctx_vars.go b/internal/engine/runtime_ctx_vars.go
new file mode 100644
index 0000000..cc1273d
--- /dev/null
+++ b/internal/engine/runtime_ctx_vars.go
@@ -0,0 +1,52 @@
+// Copyright 2023 The Shac Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package engine
+
+import (
+	"context"
+	"errors"
+	"fmt"
+
+	"go.starlark.net/starlark"
+)
+
+// ctxVarsGet implements native function ctx.vars.get().
+//
+// It returns a string, or an error if the requested variable is not a valid
+// variable listed in the project's shac.textproto config file.
+//
+// The full dictionary of available variables is intentionally not exposed to
+// user code because that would allow probing the variables from shared check
+// libraries, which would make variable names part of the API of those
+// libraries. Variables should only be used *within* a single project.
+//
+// Make sure to update //doc/stdlib.star whenever this function is modified.
+func ctxVarsGet(ctx context.Context, s *shacState, funcname string, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
+	var argname starlark.String
+	if err := starlark.UnpackArgs(funcname, args, kwargs,
+		"name", &argname,
+	); err != nil {
+		return nil, err
+	}
+	name := string(argname)
+	if name == "" {
+		return nil, errors.New("for parameter \"name\": must not be empty")
+	}
+	val, ok := s.vars[name]
+	if !ok {
+		return nil, fmt.Errorf("unknown variable %q", name)
+	}
+	return starlark.String(val), nil
+}
diff --git a/internal/engine/runtime_shac.go b/internal/engine/runtime_shac.go
index 362135a..bd647c1 100644
--- a/internal/engine/runtime_shac.go
+++ b/internal/engine/runtime_shac.go
@@ -27,7 +27,7 @@
 	// Version is the current tool version.
 	//
 	// TODO(maruel): Add proper version, preferably from git tag.
-	Version = [...]int{0, 1, 4}
+	Version = [...]int{0, 1, 5}
 )
 
 // getShac returns the global shac object.
diff --git a/internal/engine/shac.pb.go b/internal/engine/shac.pb.go
index 9bec552..918e914 100644
--- a/internal/engine/shac.pb.go
+++ b/internal/engine/shac.pb.go
@@ -58,7 +58,8 @@
 	// Whether to allow checks write access to the SCM root directory.
 	// TODO(olivernewman): Remove this option once named caches and pass-throughs
 	// are implemented.
-	WritableRoot bool `protobuf:"varint,7,opt,name=writable_root,json=writableRoot,proto3" json:"writable_root,omitempty"`
+	WritableRoot bool   `protobuf:"varint,7,opt,name=writable_root,json=writableRoot,proto3" json:"writable_root,omitempty"`
+	Vars         []*Var `protobuf:"bytes,8,rep,name=vars,proto3" json:"vars,omitempty"`
 }
 
 func (x *Document) Reset() {
@@ -142,6 +143,87 @@
 	return false
 }
 
+func (x *Document) GetVars() []*Var {
+	if x != nil {
+		return x.Vars
+	}
+	return nil
+}
+
+// Var specifies a variable that may be passed into checks at runtime by the
+// --var flag and accessed via `ctx.vars.get(name)`.
+//
+// Vars are intentionally limited in usefulness so they can only be used for
+// passing through opaque configuration strings, not for controlling behavior,
+// which would introduce the potential for divergence between environments.
+type Var struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// name is the name of the variable, as specified on the command line and as
+	// passed into `ctx.vars.get()`.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// desc is an optional description of the meaning of the variable.
+	Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
+	// default is the default value of the variable. It may be left unspecified,
+	// in which case the default is the empty string.
+	Default string `protobuf:"bytes,3,opt,name=default,proto3" json:"default,omitempty"`
+}
+
+func (x *Var) Reset() {
+	*x = Var{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_shac_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Var) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Var) ProtoMessage() {}
+
+func (x *Var) ProtoReflect() protoreflect.Message {
+	mi := &file_shac_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Var.ProtoReflect.Descriptor instead.
+func (*Var) Descriptor() ([]byte, []int) {
+	return file_shac_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Var) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Var) GetDescription() string {
+	if x != nil {
+		return x.Description
+	}
+	return ""
+}
+
+func (x *Var) GetDefault() string {
+	if x != nil {
+		return x.Default
+	}
+	return ""
+}
+
 // Requirements lists all the external dependencies, both direct and transitive
 // (indirect).
 type Requirements struct {
@@ -158,7 +240,7 @@
 func (x *Requirements) Reset() {
 	*x = Requirements{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[1]
+		mi := &file_shac_proto_msgTypes[2]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -171,7 +253,7 @@
 func (*Requirements) ProtoMessage() {}
 
 func (x *Requirements) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[1]
+	mi := &file_shac_proto_msgTypes[2]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -184,7 +266,7 @@
 
 // Deprecated: Use Requirements.ProtoReflect.Descriptor instead.
 func (*Requirements) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{1}
+	return file_shac_proto_rawDescGZIP(), []int{2}
 }
 
 func (x *Requirements) GetDirect() []*Dependency {
@@ -221,7 +303,7 @@
 func (x *Dependency) Reset() {
 	*x = Dependency{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[2]
+		mi := &file_shac_proto_msgTypes[3]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -234,7 +316,7 @@
 func (*Dependency) ProtoMessage() {}
 
 func (x *Dependency) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[2]
+	mi := &file_shac_proto_msgTypes[3]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -247,7 +329,7 @@
 
 // Deprecated: Use Dependency.ProtoReflect.Descriptor instead.
 func (*Dependency) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{2}
+	return file_shac_proto_rawDescGZIP(), []int{3}
 }
 
 func (x *Dependency) GetUrl() string {
@@ -283,7 +365,7 @@
 func (x *Sum) Reset() {
 	*x = Sum{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[3]
+		mi := &file_shac_proto_msgTypes[4]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -296,7 +378,7 @@
 func (*Sum) ProtoMessage() {}
 
 func (x *Sum) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[3]
+	mi := &file_shac_proto_msgTypes[4]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -309,7 +391,7 @@
 
 // Deprecated: Use Sum.ProtoReflect.Descriptor instead.
 func (*Sum) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{3}
+	return file_shac_proto_rawDescGZIP(), []int{4}
 }
 
 func (x *Sum) GetKnown() []*Known {
@@ -332,7 +414,7 @@
 func (x *Known) Reset() {
 	*x = Known{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[4]
+		mi := &file_shac_proto_msgTypes[5]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -345,7 +427,7 @@
 func (*Known) ProtoMessage() {}
 
 func (x *Known) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[4]
+	mi := &file_shac_proto_msgTypes[5]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -358,7 +440,7 @@
 
 // Deprecated: Use Known.ProtoReflect.Descriptor instead.
 func (*Known) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{4}
+	return file_shac_proto_rawDescGZIP(), []int{5}
 }
 
 func (x *Known) GetUrl() string {
@@ -391,7 +473,7 @@
 func (x *VersionDigest) Reset() {
 	*x = VersionDigest{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_shac_proto_msgTypes[5]
+		mi := &file_shac_proto_msgTypes[6]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -404,7 +486,7 @@
 func (*VersionDigest) ProtoMessage() {}
 
 func (x *VersionDigest) ProtoReflect() protoreflect.Message {
-	mi := &file_shac_proto_msgTypes[5]
+	mi := &file_shac_proto_msgTypes[6]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -417,7 +499,7 @@
 
 // Deprecated: Use VersionDigest.ProtoReflect.Descriptor instead.
 func (*VersionDigest) Descriptor() ([]byte, []int) {
-	return file_shac_proto_rawDescGZIP(), []int{5}
+	return file_shac_proto_rawDescGZIP(), []int{6}
 }
 
 func (x *VersionDigest) GetVersion() string {
@@ -438,7 +520,7 @@
 
 var file_shac_proto_rawDesc = []byte{
 	0x0a, 0x0a, 0x73, 0x68, 0x61, 0x63, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, 0x65, 0x6e,
-	0x67, 0x69, 0x6e, 0x65, 0x22, 0x90, 0x02, 0x0a, 0x08, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e,
+	0x67, 0x69, 0x6e, 0x65, 0x22, 0xb1, 0x02, 0x0a, 0x08, 0x44, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e,
 	0x74, 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x69, 0x6e, 0x5f, 0x73, 0x68, 0x61, 0x63, 0x5f, 0x76, 0x65,
 	0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x6d, 0x69, 0x6e,
 	0x53, 0x68, 0x61, 0x63, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x61,
@@ -455,34 +537,41 @@
 	0x6e, 0x6f, 0x72, 0x65, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x06, 0x69, 0x67, 0x6e, 0x6f,
 	0x72, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x77, 0x72, 0x69, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x5f, 0x72,
 	0x6f, 0x6f, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x77, 0x72, 0x69, 0x74, 0x61,
-	0x62, 0x6c, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x22, 0x6a, 0x0a, 0x0c, 0x52, 0x65, 0x71, 0x75, 0x69,
-	0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x2a, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63,
-	0x74, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65,
-	0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x52, 0x06, 0x64, 0x69, 0x72,
-	0x65, 0x63, 0x74, 0x12, 0x2e, 0x0a, 0x08, 0x69, 0x6e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18,
-	0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x44,
-	0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x52, 0x08, 0x69, 0x6e, 0x64, 0x69, 0x72,
-	0x65, 0x63, 0x74, 0x22, 0x4e, 0x0a, 0x0a, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
-	0x79, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03,
-	0x75, 0x72, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x18, 0x02, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x61, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72,
-	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73,
-	0x69, 0x6f, 0x6e, 0x22, 0x2a, 0x0a, 0x03, 0x53, 0x75, 0x6d, 0x12, 0x23, 0x0a, 0x05, 0x6b, 0x6e,
-	0x6f, 0x77, 0x6e, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x65, 0x6e, 0x67, 0x69,
-	0x6e, 0x65, 0x2e, 0x4b, 0x6e, 0x6f, 0x77, 0x6e, 0x52, 0x05, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x22,
-	0x44, 0x0a, 0x05, 0x4b, 0x6e, 0x6f, 0x77, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18,
-	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x29, 0x0a, 0x04, 0x73, 0x65,
-	0x65, 0x6e, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e,
-	0x65, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x52,
-	0x04, 0x73, 0x65, 0x65, 0x6e, 0x22, 0x41, 0x0a, 0x0d, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
-	0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
-	0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
-	0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
-	0x52, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74, 0x42, 0x32, 0x5a, 0x30, 0x67, 0x6f, 0x2e, 0x66,
-	0x75, 0x63, 0x68, 0x73, 0x69, 0x61, 0x2e, 0x64, 0x65, 0x76, 0x2f, 0x73, 0x68, 0x61, 0x63, 0x2d,
-	0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f, 0x73, 0x68, 0x61, 0x63, 0x2f, 0x69, 0x6e, 0x74,
-	0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x62, 0x06, 0x70, 0x72,
-	0x6f, 0x74, 0x6f, 0x33,
+	0x62, 0x6c, 0x65, 0x52, 0x6f, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x04, 0x76, 0x61, 0x72, 0x73, 0x18,
+	0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x56,
+	0x61, 0x72, 0x52, 0x04, 0x76, 0x61, 0x72, 0x73, 0x22, 0x55, 0x0a, 0x03, 0x56, 0x61, 0x72, 0x12,
+	0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
+	0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
+	0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69,
+	0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x22,
+	0x6a, 0x0a, 0x0c, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12,
+	0x2a, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x12, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65,
+	0x6e, 0x63, 0x79, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x12, 0x2e, 0x0a, 0x08, 0x69,
+	0x6e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e,
+	0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x44, 0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63,
+	0x79, 0x52, 0x08, 0x69, 0x6e, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x22, 0x4e, 0x0a, 0x0a, 0x44,
+	0x65, 0x70, 0x65, 0x6e, 0x64, 0x65, 0x6e, 0x63, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x61,
+	0x6c, 0x69, 0x61, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x61, 0x6c, 0x69, 0x61,
+	0x73, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x2a, 0x0a, 0x03, 0x53,
+	0x75, 0x6d, 0x12, 0x23, 0x0a, 0x05, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x18, 0x01, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x0d, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x4b, 0x6e, 0x6f, 0x77, 0x6e,
+	0x52, 0x05, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x22, 0x44, 0x0a, 0x05, 0x4b, 0x6e, 0x6f, 0x77, 0x6e,
+	0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75,
+	0x72, 0x6c, 0x12, 0x29, 0x0a, 0x04, 0x73, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x15, 0x2e, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+	0x6e, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x52, 0x04, 0x73, 0x65, 0x65, 0x6e, 0x22, 0x41, 0x0a,
+	0x0d, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x44, 0x69, 0x67, 0x65, 0x73, 0x74, 0x12, 0x18,
+	0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x67, 0x65,
+	0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x69, 0x67, 0x65, 0x73, 0x74,
+	0x42, 0x32, 0x5a, 0x30, 0x67, 0x6f, 0x2e, 0x66, 0x75, 0x63, 0x68, 0x73, 0x69, 0x61, 0x2e, 0x64,
+	0x65, 0x76, 0x2f, 0x73, 0x68, 0x61, 0x63, 0x2d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2f,
+	0x73, 0x68, 0x61, 0x63, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x65, 0x6e,
+	0x67, 0x69, 0x6e, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -497,27 +586,29 @@
 	return file_shac_proto_rawDescData
 }
 
-var file_shac_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
+var file_shac_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
 var file_shac_proto_goTypes = []interface{}{
 	(*Document)(nil),      // 0: engine.Document
-	(*Requirements)(nil),  // 1: engine.Requirements
-	(*Dependency)(nil),    // 2: engine.Dependency
-	(*Sum)(nil),           // 3: engine.Sum
-	(*Known)(nil),         // 4: engine.Known
-	(*VersionDigest)(nil), // 5: engine.VersionDigest
+	(*Var)(nil),           // 1: engine.Var
+	(*Requirements)(nil),  // 2: engine.Requirements
+	(*Dependency)(nil),    // 3: engine.Dependency
+	(*Sum)(nil),           // 4: engine.Sum
+	(*Known)(nil),         // 5: engine.Known
+	(*VersionDigest)(nil), // 6: engine.VersionDigest
 }
 var file_shac_proto_depIdxs = []int32{
-	1, // 0: engine.Document.requirements:type_name -> engine.Requirements
-	3, // 1: engine.Document.sum:type_name -> engine.Sum
-	2, // 2: engine.Requirements.direct:type_name -> engine.Dependency
-	2, // 3: engine.Requirements.indirect:type_name -> engine.Dependency
-	4, // 4: engine.Sum.known:type_name -> engine.Known
-	5, // 5: engine.Known.seen:type_name -> engine.VersionDigest
-	6, // [6:6] is the sub-list for method output_type
-	6, // [6:6] is the sub-list for method input_type
-	6, // [6:6] is the sub-list for extension type_name
-	6, // [6:6] is the sub-list for extension extendee
-	0, // [0:6] is the sub-list for field type_name
+	2, // 0: engine.Document.requirements:type_name -> engine.Requirements
+	4, // 1: engine.Document.sum:type_name -> engine.Sum
+	1, // 2: engine.Document.vars:type_name -> engine.Var
+	3, // 3: engine.Requirements.direct:type_name -> engine.Dependency
+	3, // 4: engine.Requirements.indirect:type_name -> engine.Dependency
+	5, // 5: engine.Sum.known:type_name -> engine.Known
+	6, // 6: engine.Known.seen:type_name -> engine.VersionDigest
+	7, // [7:7] is the sub-list for method output_type
+	7, // [7:7] is the sub-list for method input_type
+	7, // [7:7] is the sub-list for extension type_name
+	7, // [7:7] is the sub-list for extension extendee
+	0, // [0:7] is the sub-list for field type_name
 }
 
 func init() { file_shac_proto_init() }
@@ -539,7 +630,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Requirements); i {
+			switch v := v.(*Var); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -551,7 +642,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Dependency); i {
+			switch v := v.(*Requirements); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -563,7 +654,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Sum); i {
+			switch v := v.(*Dependency); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -575,7 +666,7 @@
 			}
 		}
 		file_shac_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*Known); i {
+			switch v := v.(*Sum); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -587,6 +678,18 @@
 			}
 		}
 		file_shac_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Known); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_shac_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*VersionDigest); i {
 			case 0:
 				return &v.state
@@ -605,7 +708,7 @@
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_shac_proto_rawDesc,
 			NumEnums:      0,
-			NumMessages:   6,
+			NumMessages:   7,
 			NumExtensions: 0,
 			NumServices:   0,
 		},
diff --git a/internal/engine/shac.proto b/internal/engine/shac.proto
index d83568c..cfc6db6 100644
--- a/internal/engine/shac.proto
+++ b/internal/engine/shac.proto
@@ -39,6 +39,24 @@
   // TODO(olivernewman): Remove this option once named caches and pass-throughs
   // are implemented.
   bool writable_root = 7;
+  repeated Var vars = 8;
+}
+
+// Var specifies a variable that may be passed into checks at runtime by the
+// --var flag and accessed via `ctx.vars.get(name)`.
+//
+// Vars are intentionally limited in usefulness so they can only be used for
+// passing through opaque configuration strings, not for controlling behavior,
+// which would introduce the potential for divergence between environments.
+message Var {
+  // name is the name of the variable, as specified on the command line and as
+  // passed into `ctx.vars.get()`.
+  string name = 1;
+  // desc is an optional description of the meaning of the variable.
+  string description = 2;
+  // default is the default value of the variable. It may be left unspecified,
+  // in which case the default is the empty string.
+  string default = 3;
 }
 
 // Requirements lists all the external dependencies, both direct and transitive
diff --git a/internal/engine/testdata/ctx-var-value.star b/internal/engine/testdata/ctx-var-value.star
new file mode 100644
index 0000000..6da601f
--- /dev/null
+++ b/internal/engine/testdata/ctx-var-value.star
@@ -0,0 +1,18 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+def cb(ctx):
+    print(ctx.vars.get("foo"))
+
+shac.register_check(cb)
diff --git a/internal/engine/testdata/fail_or_throw/ctx-vars-empty.star b/internal/engine/testdata/fail_or_throw/ctx-vars-empty.star
new file mode 100644
index 0000000..f8dd8bf
--- /dev/null
+++ b/internal/engine/testdata/fail_or_throw/ctx-vars-empty.star
@@ -0,0 +1,18 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+def cb(ctx):
+    ctx.vars.get("")
+
+shac.register_check(cb)
diff --git a/internal/engine/testdata/fail_or_throw/ctx-vars-invalid.star b/internal/engine/testdata/fail_or_throw/ctx-vars-invalid.star
new file mode 100644
index 0000000..c92d030
--- /dev/null
+++ b/internal/engine/testdata/fail_or_throw/ctx-vars-invalid.star
@@ -0,0 +1,18 @@
+# Copyright 2023 The Shac Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+def cb(ctx):
+    ctx.vars.get("invalid_var")
+
+shac.register_check(cb)
diff --git a/shac.textproto b/shac.textproto
index 0e39914..8d73af9 100644
--- a/shac.textproto
+++ b/shac.textproto
@@ -15,7 +15,7 @@
 # TODO(olivernewman): Build github.com/protocolbuffers/txtpbfmt into shac and
 # enforce formatting of shac.textproto files in all repos that use shac.
 
-min_shac_version: "0.1.0"
+min_shac_version: "0.1.5"
 allow_network: False
 ignore: "/vendor/"
 # Vendored code for test data only.
@@ -25,3 +25,10 @@
 # pass-throughs to avoid having checks install tools and do Go builds within the
 # checkout directory.
 writable_root: true
+vars: [
+    {
+        name: "example_var"
+        description: "An example runtime variable, not used anywhere"
+        default: "foo"
+    }
+]