diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83a1e699..e83bed08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -353,7 +353,9 @@ jobs: mix test \ test/rules/actions_lock_test.exs \ test/rules/actions_lock_closure_corpus_test.exs \ - test/rules/rsr_conformance_test.exs + test/rules/rsr_conformance_test.exs \ + test/research_extensions_test.exs \ + test/deed_test.exs # The consumers' invocation, verbatim: the oracle is called with an # EXPLICIT --ssot. The default SSOT resolution is deliberately not diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index efe728d4..a6ccd388 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,38 @@ https://semver.org/[Semantic Versioning]. == [Unreleased] +=== Changed + +==== RE001 accepts a deed egress declaration; the job token no longer counts (2026-10-27) + +The estate-wide decision on `step-security/harden-runner` (standards ADR-009) is +to declare egress instead of adopting harden-runner everywhere. RE001 now: + +* ignores `secrets.GITHUB_TOKEN`, the same token as `github.token`, which it + never counted; +* accepts a job listed in the `(egress …)` clause of the repo deed + (`_chora.deed`) with exactly the secrets the job reads, as an + alternative to harden-runner; +* reports drift (the declaration and the workflow disagree on secrets) and stale + declarations (the job is gone, runs elsewhere, or reads no real secret), and + checks a declaration even when harden-runner satisfies the job, including a + claimed `:enforcement harden-runner-*` on a job that no longer installs it; +* names the job and gives the exact `(job …)` entry in its fix text; +* reads every `${{ … }}` expression whole, even one a folded scalar splits + across lines, and counts only the top-level `secrets` context (matched + without regard to case), in property or index form, outside string + literals; a read by a key computed at run time (`secrets[env.X]`, + `toJSON(secrets)`) is its own finding, since no declaration can list it; +* keeps #966's exposure model as the severity layer: an undeclared job is + `:high` when the credential shares a step with fetched or untrusted code, + and otherwise `:warn`, with or without an exposure (owner ruling + 2026-10-27: ADR-009 asks every secret-bearing job to declare). + +`Hypatia.Deed` is a new reader for DEED documents (`deed.abnf` v1.2.1) and the +`(egress …)` clause profile. `test/deed_test.exs` checks it against the +standards fixtures and the template's live deed. An apg-js run of the grammar +and the reader agree on 20,043 mutated deeds, semantic checks apart. + === Fixed ==== `mix compile` was impossible: invalid guard-as-pattern in `ActionsLock` (2026-10-25) diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex new file mode 100644 index 00000000..ac633247 --- /dev/null +++ b/lib/hypatia/deed.ex @@ -0,0 +1,446 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +defmodule Hypatia.Deed do + @moduledoc """ + Reader for DEED documents (`metadatastician/standards` + `1-formats/deed/spec/abnf/deed.abnf` v1.2.1) and for the `(egress …)` clause + profile (`1-formats/deed/spec/abnf/clauses/egress.abnf` v1.0.0, ADR-009). + + This is a *reader*, not the conformance gate. `deed_lint.js` in standards is + the gate, and runs in every repo's `deed-conformance` lane. The reader + produces the same tree the gate does, so a consumer can read a clause: + + {head, items} + item = {:field, keyword, value} | {:clause, symbol, [item]} + value = {:string, s} | {:symbol, s} | {:integer, n} | {:boolean, b} + | {:uuid5, s} | {:uuid8, s} | {:quoted, value} | {:list, [value]} + + It accepts a deed only when both the normative grammar and the gate would: + `deed.abnf`, plus the gate's semantic checks (one string `:schema-version`, + a non-blank SPDX payload, a `#u8` domain). Where the two differ, the + stricter reading wins, so a deed the reader refuses only ever declares + nothing. It reports the reason. v1.2.1 (metadatastician/standards#1359) + made the grammar and the gate agree; an apg-js run of `deed.abnf` and this + reader agree on 20,043 mutated deeds, semantic checks apart. + """ + + @doc "Parse deed source text into `{:ok, {head, items}}` or `{:error, reason}`." + def parse(text) when is_binary(text) do + with true <- String.valid?(text) || {:error, "a deed is UTF-8"}, + {:ok, rest} <- header(text), + {:ok, {head, items}, rest} <- form(skip(rest)), + "" <- skip(rest), + :ok <- schema_version(items) do + {:ok, {head, items}} + else + {:error, _} = err -> err + rest when is_binary(rest) -> {:error, "trailing content after the form"} + end + end + + # form: exactly one :schema-version field, with a string value. + defp schema_version(items) do + case for({:field, "schema-version", v} <- items, do: v) do + [{:string, _}] -> + :ok + + [_] -> + {:error, ":schema-version must be a string"} + + found -> + {:error, "the form must carry exactly one :schema-version (found #{length(found)})"} + end + end + + @doc """ + The repo deed at the root of `repo_path`: `{:ok, path}`, `:none`, or + `{:error, reason}` when there is more than one (one deed per repo). + `estate_chora.deed` is the estate deed, never a repo deed. + """ + def repo_deed_path(repo_path) do + case File.ls(repo_path) do + {:ok, names} -> + names + |> Enum.filter(&(String.ends_with?(&1, "_chora.deed") and &1 != "estate_chora.deed")) + |> Enum.sort() + |> case do + [] -> + :none + + [one] -> + # stem = 1*( ALPHA / DIGIT / "-" / "." / "_" ), as deed.abnf has it. + if Regex.match?(~r/\A[A-Za-z0-9._-]+_chora\.deed\z/, one), + do: {:ok, Path.join(repo_path, one)}, + else: {:error, "illegal deed filename stem: #{one}"} + + many -> + {:error, "more than one repo deed: #{Enum.join(many, ", ")}"} + end + + {:error, _} -> + :none + end + end + + @doc """ + The jobs declared in the repo deed's `(egress …)` clause. + + Returns `{:ok, deed_file, jobs}` where each job is + `%{workflow: path, job: id, secrets: MapSet, enforcement: name}`, `:none` when + the repo has no deed, or `{:error, deed_file, reason}` when the deed or the clause cannot be + read. A deed without the clause declares nothing: `{:ok, deed_file, []}`. + """ + def egress_declarations(repo_path) do + case repo_deed_path(repo_path) do + :none -> + :none + + {:error, reason} -> + {:error, nil, reason} + + {:ok, path} -> + rel = Path.relative_to(path, repo_path) + + with {:ok, text} <- File.read(path), + {:ok, {head, items}} <- parse(text), + # Filename dispatch: _chora.deed parses as a repo-deed. + true <- + head == "repo-deed" || + {:error, "doc-head/filename mismatch: #{rel} must be a repo-deed, not #{head}"}, + {:ok, jobs} <- egress_jobs(head, items) do + {:ok, rel, jobs} + else + {:error, reason} when is_binary(reason) -> {:error, rel, reason} + {:error, posix} -> {:error, rel, "cannot read: #{posix}"} + end + end + end + + # ─── (egress …) profile ───────────────────────────────────────────── + + @required ~w(workflow job secrets endpoints enforcement) + @allowed MapSet.new(["reason" | @required]) + @enforcement ~w(none harden-runner-audit harden-runner-block) + # Every "." follows a non-empty name segment, as workflow-path in egress.abnf. + @workflow ~r/\A\.github\/workflows\/(?:[A-Za-z0-9_-]+\.)+ya?ml\z/ + @job_id ~r/\A[A-Za-z_][A-Za-z0-9_-]*\z/ + # The stored form: GitHub keeps secret and variable names uppercase and + # matches references to them without regard to case. + @secret ~r/\A[A-Z_][A-Z0-9_]*\z/ + # Labels start and end with a letter or digit (RFC 1123 §2.1). + @host_port ~r/\A(\*\.)?[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*(?::(?[0-9]{1,5}))?\z/ + + defp egress_jobs(head, items) do + # The clause counts only at the top level; one nested deeper would declare + # nothing without saying so, so it is refused. + nested? = + Enum.any?(items, fn item -> + not match?({:clause, "egress", _}, item) and egress_below?(item) + end) + + case for({:clause, "egress", members} <- items, do: members) do + _ when nested? -> {:error, "(egress …) nested inside another clause"} + [] -> {:ok, []} + [_ | [_ | _]] -> {:error, "more than one (egress …) clause"} + [_] when head != "repo-deed" -> {:error, "(egress …) outside a repo-deed"} + [members] -> egress_members(members) + end + end + + defp egress_below?({:clause, _, inner}), + do: Enum.any?(inner, &(match?({:clause, "egress", _}, &1) or egress_below?(&1))) + + defp egress_below?(_), do: false + + defp egress_members(members) do + if Enum.count(members, &match?({:field, "policy", _}, &1)) > 1, + do: {:error, "(egress …) repeats :policy"}, + else: egress_jobs_of(members) + end + + defp egress_jobs_of(members) do + members + |> Enum.reduce_while({:ok, []}, fn + {:field, "policy", {:string, _}}, acc -> {:cont, acc} + {:clause, "job", fields}, {:ok, jobs} -> egress_job(fields, jobs) + other, _ -> {:halt, {:error, "(egress …) cannot hold #{describe(other)}"}} + end) + |> case do + {:ok, jobs} -> + keys = Enum.map(jobs, &{&1.workflow, &1.job}) + + if length(Enum.uniq(keys)) == length(keys), + do: {:ok, Enum.reverse(jobs)}, + else: {:error, "(egress …) lists the same workflow job twice"} + + err -> + err + end + end + + defp egress_job(fields, jobs) do + by_key = for {:field, k, v} <- fields, into: %{}, do: {k, v} + + cond do + Enum.any?(fields, &(not match?({:field, _, _}, &1))) -> + {:halt, {:error, "(job …) holds a nested clause"}} + + length(fields) != map_size(by_key) -> + {:halt, {:error, "(job …) repeats a field"}} + + (unknown = Enum.reject(Map.keys(by_key), &MapSet.member?(@allowed, &1))) != [] -> + {:halt, {:error, "(job …) has no field :#{hd(unknown)}"}} + + (missing = Enum.reject(@required, &Map.has_key?(by_key, &1))) != [] -> + {:halt, {:error, "(job …) is missing :#{hd(missing)}"}} + + true -> + with {:string, workflow} <- by_key["workflow"], + true <- Regex.match?(@workflow, workflow), + {:string, job} <- by_key["job"], + true <- Regex.match?(@job_id, job), + {:ok, secrets} <- strings(by_key["secrets"]), + true <- valid_secrets?(secrets), + {:ok, endpoints} <- strings(by_key["endpoints"]), + true <- Enum.all?(endpoints, &valid_endpoint?(&1, secrets)), + {:symbol, enf} when enf in @enforcement <- by_key["enforcement"], + true <- reason_ok?(by_key["reason"]) do + job = %{workflow: workflow, job: job, secrets: MapSet.new(secrets), enforcement: enf} + {:cont, {:ok, [job | jobs]}} + else + _ -> + {:halt, {:error, "(job …) has a field of the wrong shape (standards egress.abnf)"}} + end + end + end + + defp valid_secrets?(secrets) do + secrets != [] and "GITHUB_TOKEN" not in secrets and + Enum.all?(secrets, &Regex.match?(@secret, &1)) and + length(Enum.uniq(secrets)) == length(secrets) + end + + defp valid_endpoint?("secret:" <> name, secrets), do: name in secrets + defp valid_endpoint?("var:" <> name, _secrets), do: Regex.match?(@secret, name) + + defp valid_endpoint?(endpoint, _secrets) do + case Regex.named_captures(@host_port, endpoint) do + nil -> false + %{"port" => port} -> port_ok?(port) + end + end + + defp port_ok?(port) when port in [nil, ""], do: true + defp port_ok?(port), do: String.to_integer(port) in 1..65_535 + + defp reason_ok?(nil), do: true + defp reason_ok?({:string, _}), do: true + defp reason_ok?(_), do: false + + defp strings({:list, values}) do + if Enum.all?(values, &match?({:string, _}, &1)), + do: {:ok, Enum.map(values, &elem(&1, 1))}, + else: :error + end + + defp strings(_), do: :error + + defp describe({:field, k, _}), do: ":#{k}" + defp describe({:clause, s, _}), do: "(#{s} …)" + + # ─── Lexical layer ────────────────────────────────────────────────── + + # text-char = VCHAR / SP / UTF8-2 / UTF8-3 / UTF8-4; line-end = CRLF / LF. + # The input is valid UTF-8 (checked in parse/1), so a code point at or above + # U+0080 is one of the UTF8-n forms. + @text_chars "[\\x{20}-\\x{7E}\\x{80}-\\x{10FFFF}]" + @spdx_line Regex.compile!("\\A;; SPDX-(#{@text_chars}+)(?:\\r\\n|\\n)", "u") + @comment_tail Regex.compile!("\\A#{@text_chars}*(?:\\r\\n|\\n)", "u") + + # header = 1*spdx-line; spdx-line = ";;" SP "SPDX-" 1*text-char line-end. + # The gate also refuses a payload that is only spaces. + defp header(text), do: spdx_lines(text, 0) + + defp spdx_lines(text, n) do + case Regex.run(@spdx_line, text) do + [line, payload] -> + if String.trim(payload) == "", + do: {:error, "SPDX header line is empty after ';; SPDX-'"}, + else: spdx_lines(drop(text, line), n + 1) + + nil when n == 0 -> + {:error, "no ';; SPDX-' header line"} + + nil -> + {:ok, text} + end + end + + # token-sep = 1*(SP / line-end / comment); comment = ";" *text-char line-end. + # A bare CR, a tab, or a comment without its line-end is not a separator; + # skip/1 stops there and the caller reports what it cannot read. + defp skip(" " <> rest), do: skip(rest) + defp skip("\n" <> rest), do: skip(rest) + defp skip("\r\n" <> rest), do: skip(rest) + + defp skip(";" <> rest = text) do + case Regex.run(@comment_tail, rest) do + [tail] -> skip(drop(rest, tail)) + nil -> text + end + end + + defp skip(rest), do: rest + + defp drop(text, prefix), + do: binary_part(text, byte_size(prefix), byte_size(text) - byte_size(prefix)) + + defp form("(" <> rest) do + with {:ok, head, rest} <- symbol(rest), + true <- head in ~w(estate-deed repo-deed estate-atlas-deed praxis-deed) || :bad_head, + {:ok, items, rest} <- items(rest, []) do + {:ok, {head, items}, rest} + else + :bad_head -> {:error, "invalid doc-head"} + {:error, _} = err -> err + end + end + + defp form(_), do: {:error, "a deed form must start with '('"} + + # *(token-sep (field / clause)) [token-sep] ")" — every item is preceded by + # a separator; only the closing paren may follow without one. + defp items(text, acc) do + case skip(text) do + ")" <> rest -> {:ok, Enum.reverse(acc), rest} + "" -> {:error, "unbalanced parens"} + ^text -> {:error, "expected a separator before the next field or clause"} + next -> with {:ok, item, rest} <- item(next), do: items(rest, [item | acc]) + end + end + + # field = keyword token-sep value. As the gate does, a field's value may not + # be a boolean spelled as a word: booleans are #t and #f. + defp item(":" <> rest) do + with {:ok, kw, rest} <- symbol(rest), + {:ok, rest} <- separator(rest, ":#{kw}"), + {:ok, value, rest} <- value(rest) do + case value do + {:symbol, word} when word in ~w(true false yes no) -> + {:error, ":#{kw} #{word} is not a boolean: write #t or #f"} + + _ -> + {:ok, {:field, kw, value}, rest} + end + end + end + + defp item("(" <> rest) do + with {:ok, head, rest} <- symbol(rest), + {:ok, inner, rest} <- items(rest, []) do + {:ok, {:clause, head, inner}, rest} + end + end + + defp item(_), do: {:error, "expected a field or a clause"} + + defp separator(text, after_what) do + case skip(text) do + ^text -> {:error, "#{after_what} must be followed by a separator"} + rest -> {:ok, rest} + end + end + + defp value("\"" <> rest), do: string(rest, []) + defp value("#u5\"" <> rest), do: tagged(:uuid5, string(rest, [])) + defp value("#u8\"" <> rest), do: uuid8(string(rest, [])) + defp value("#t" <> rest), do: {:ok, {:boolean, true}, rest} + defp value("#f" <> rest), do: {:ok, {:boolean, false}, rest} + defp value("(" <> rest), do: list_open(rest) + + # quoted = "'" ( symbol / list ): no separator after the quote, and nothing + # else may be quoted. + defp value("'" <> rest) do + case value(rest) do + {:ok, {kind, _} = v, rest} when kind in [:symbol, :list] -> {:ok, {:quoted, v}, rest} + {:ok, _, _} -> {:error, "only a symbol or a list may be quoted"} + err -> err + end + end + + defp value(text) do + case Regex.run(~r/\A-?[0-9]+/, text) do + [digits] -> + {:ok, {:integer, String.to_integer(digits)}, + binary_part(text, byte_size(digits), byte_size(text) - byte_size(digits))} + + nil -> + case symbol(text) do + {:ok, s, rest} -> {:ok, {:symbol, s}, rest} + err -> err + end + end + end + + defp tagged(tag, {:ok, {:string, s}, rest}), do: {:ok, {tag, s}, rest} + defp tagged(_tag, err), do: err + + # uuid8 body: domain ":" name, the domain (before the first ":") non-empty + # printable ASCII (ADR-008 profile C), as the gate checks. + defp uuid8({:ok, {:string, s}, _} = ok) do + if Regex.match?(~r/\A[\x21-\x39\x3B-\x7E]+:/, s), + do: tagged(:uuid8, ok), + else: {:error, "a #u8 body must be domain:name with a printable ASCII domain"} + end + + defp uuid8(err), do: err + + # list = "(" [token-sep] [value *(token-sep value)] [token-sep] ")", as + # v1.2.1 has it: a multi-line list may start on the line after "(", as the + # estate template writes them. + defp list_open(text) do + case skip(text) do + ")" <> rest -> {:ok, {:list, []}, rest} + next -> list(next, []) + end + end + + defp list(text, acc) do + with {:ok, v, rest} <- value(text) do + case skip(rest) do + ")" <> after_list -> {:ok, {:list, Enum.reverse([v | acc])}, after_list} + ^rest -> {:error, "list values must be separated"} + next -> list(next, [v | acc]) + end + end + end + + # string = DQUOTE *(str-char / escape) DQUOTE; exactly four escapes. + defp string("\"" <> rest, acc), + do: {:ok, {:string, IO.iodata_to_binary(Enum.reverse(acc))}, rest} + + defp string("\\\"" <> rest, acc), do: string(rest, ["\"" | acc]) + defp string("\\\\" <> rest, acc), do: string(rest, ["\\" | acc]) + defp string("\\n" <> rest, acc), do: string(rest, ["\n" | acc]) + defp string("\\t" <> rest, acc), do: string(rest, ["\t" | acc]) + defp string("\\" <> _, _acc), do: {:error, "illegal string escape"} + defp string("", _acc), do: {:error, "unterminated string"} + # str-char = %x20-21 / %x23-5B / %x5D-7E / UTF8-2 / UTF8-3 / UTF8-4 + defp string(<>, acc) when c in 0x20..0x7E or c >= 0x80, + do: string(rest, [<> | acc]) + + defp string(<>, _acc), + do: + {:error, "raw control character U+#{Integer.to_string(c, 16)} in a string (use \\n or \\t)"} + + defp string(_, _acc), do: {:error, "invalid UTF-8 in a string"} + + @symbol ~r/\A[A-Za-z][A-Za-z0-9.*\/<>=!?+_-]*/ + defp symbol(text) do + case Regex.run(@symbol, text) do + [s] -> {:ok, s, binary_part(text, byte_size(s), byte_size(text) - byte_size(s))} + nil -> {:error, "expected a symbol"} + end + end +end diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 0035836b..7966247f 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -22,13 +22,14 @@ defmodule Hypatia.Rules.ResearchExtensions do Each rule cites its upstream source (tool name and/or paper). - **RE001** — A job holds a real credential (any `secrets.*` other - than the ambient `GITHUB_TOKEN`) alongside code it cannot vouch for - (an untrusted-input trigger, a PR-head checkout, `curl | sh`, or a - dependency install/build), and does not install - `step-security/harden-runner`. StepSecurity's Harden-Runner - provides outbound-egress monitoring/blocking; without it, nothing - watches the path by which that code would exfiltrate the - credential. From StepSecurity Harden-Runner deployment guide. + than the ambient `GITHUB_TOKEN`) but neither installs + `step-security/harden-runner` nor declares its egress in the repo + deed (standards ADR-009). It is `:high` when the credential shares + a step with code the job cannot vouch for (an untrusted-input + trigger, a PR-head checkout, `curl | sh`, or a dependency + install/build). StepSecurity's Harden-Runner provides outbound-egress + monitoring/blocking; the declaration records the boundary for it. + From StepSecurity Harden-Runner deployment guide. - **RE002** — Harden-Runner is installed but configured with `egress-policy: audit` (telemetry-only, never blocks). On a protected-branch workflow this provides no actual containment. @@ -158,7 +159,7 @@ defmodule Hypatia.Rules.ResearchExtensions do end end - # ─── RE001: credential exposed to unvetted code, no Harden-Runner ───── + # ─── RE001: secret-bearing job neither hardened nor declared ───────── # Events whose payload carries text or code authored by people without # write access to the repository. @@ -190,39 +191,55 @@ defmodule Hypatia.Rules.ResearchExtensions do @re001_fetched_code ~r/(?:curl|wget)\b[^|;&]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b|\b(?:npm|pnpm|yarn)\s+(?:i|install|ci)\b|\bnpx\s|\bpip3?\s+install\b|\b(?:pipx|uvx)\s|\bcargo\s+(?:install|build|test|run)\b|\bgo\s+(?:install|build|test|run)\b|\bgem\s+install\b|\bbundle\s+install\b|\bmix\s+(?:deps\.get|compile|test)\b|\bdeno\s+(?:run|install)\b|\bbun\s+(?:i|install|run)\b|\bopam\s+install\b|\bcabal\s+(?:install|build|test)\b|\bstack\s+(?:build|test)\b/ @doc """ - RE001: Reports each locally executed workflow job that holds a real - credential next to code it cannot vouch for, without a - `step-security/harden-runner@...` step in the same job. - - A job is reported when all of these hold: - - * it references `${{ secrets.X }}` for some `X` other than - `GITHUB_TOKEN`. That one is the ambient `github.token` every job holds - anyway, so a job naming only it exposes nothing new; - * it has at least one **exposure**: the workflow runs on an - untrusted-input event (`pull_request_target`, `issue_comment`, - `issues`, `workflow_run`, …), the job checks out or fetches the head of - a pull request or triggering run (a `ref:`/`repository:` or a git - checkout/fetch/switch naming it; merely naming the head SHA in `env:` - or `if:` is not an exposure), or a step pipes a download into a - shell or installs or builds dependencies (`npm ci`, `cargo build`, - `mix deps.get`, …); - * it does not install harden-runner. - - Severity is `:high` when the credential and a code exposure share one - step. That code has the credential in its own environment, so nothing - has to be smuggled across steps. Otherwise it is `:warn`: code in one step - can still poison the workspace or tools a later credentialed step uses. - - A credential in a job that runs only pinned actions and its own scripts - is not reported. Pinning is `actions.lock`'s job, and harden-runner adds - little there. Known gap: code fetched by other means, such as `git clone` - followed by running the clone, is not recognised. + RE001: every locally executed workflow job that reads a real secret either + installs `step-security/harden-runner@...` or declares its egress in the + repo deed (standards ADR-009). Each other such job is reported, more + severely when the credential sits next to code the job cannot vouch for. + + A job is in scope when it references a secret other than `GITHUB_TOKEN` + (the ambient `github.token` every job holds anyway). It is satisfied by: + + * a harden-runner step in the job, or + * an entry in the `(egress …)` clause of the repo deed + (`_chora.deed`, profile + `metadatastician/standards/1-formats/deed/spec/abnf/clauses/egress.abnf`) + whose `(:workflow, :job)` names the job and whose `:secrets` equal the + secrets it references. + + An undeclared job is reported. Its **exposures** are the evidence: + + * the workflow runs on an untrusted-input event (`pull_request_target`, + `issue_comment`, `issues`, `workflow_run`, …), narrowed by a job `if:` + that admits only listed events; + * the job checks out or fetches the head of a pull request or triggering + run (a checkout's `ref:`/`repository:`, or a git checkout/fetch/switch + naming it; merely naming the head SHA in `env:` or `if:` is not one); + * a step pipes a download into a shell or installs or builds dependencies + (`npm ci`, `cargo build`, `mix deps.get`, …). + + Severity is `:high` when the credential and such code share one step (the + code has the credential in its own environment), otherwise `:warn`, with + or without exposures: ADR-009 asks every secret-bearing job to declare. + + Secrets are read the way GitHub reads them: across each `${{ … }}` in the + job, even one a folded scalar splits over lines; `secrets.NAME` and + `secrets['NAME']`, the context matched without regard to case; string + literals skipped; only the top-level context counted. A read by a key + computed at run time (`secrets[env.X]`, `toJSON(secrets)`, `secrets.*`) + cannot be listed, so no declaration covers that job. + + The deed is trusted only as far as it is still true: + + * a listed job whose `:secrets` differ from its references (drift); + * a listed job that no longer exists, runs elsewhere, or reads no real + secret (stale), reported against the deed. + + An unreadable deed or clause declares nothing, and the finding says why. + `:endpoints` are not compared with the workflow: that needs run-time + observation or a model of what each action reaches. Full-line comments and reusable-workflow jobs without a direct `runs-on` - field are ignored. Each finding points to the credential reference: the - one in the exposed step when there is one, else the first in the job. - Provenance: StepSecurity Harden-Runner deployment guide. + field are ignored. Provenance: StepSecurity Harden-Runner deployment guide. ## Why this is structural, not a regex @@ -250,102 +267,355 @@ defmodule Hypatia.Rules.ResearchExtensions do Severity: `:high` or `:warn`, as above. Action: `:report`. """ def re001_missing_harden_runner(repo_path) do - repo_path - |> workflow_files() - |> Enum.flat_map(fn path -> - content = read_workflow(path) - rel = Path.relative_to(path, repo_path) + {deed_file, declared, deed_note} = + case Hypatia.Deed.egress_declarations(repo_path) do + {:ok, file, jobs} -> {file, Map.new(jobs, &{{&1.workflow, &1.job}, &1}), nil} + :none -> {nil, %{}, nil} + {:error, file, reason} -> {file, %{}, "the repo deed declares nothing (#{reason})"} + end + + jobs = + repo_path + |> workflow_files() + |> Enum.flat_map(fn path -> re001_jobs(path, Path.relative_to(path, repo_path)) end) + + job_findings = + Enum.flat_map(jobs, fn job -> + case Map.fetch(declared, {job.workflow, job.id}) do + :error -> + cond do + job.hardened? -> [] + # A key computed at run time cannot be listed, so no declaration covers it. + job.dynamic_line -> [re001_dynamic(job, deed_file, false)] + true -> [re001_undeclared(job, deed_file, deed_note)] + end + + # A declaration is an attestation, checked even when harden-runner + # already satisfies the rule: what it lists, and the enforcement it + # claims, must still be true. + {:ok, decl} -> + dynamic = + if job.dynamic_line && not job.hardened?, + do: [re001_dynamic(job, deed_file, true)], + else: [] + + drift = + if job.dynamic_line == nil and decl.secrets != job.secrets, + do: [re001_drift(job, deed_file, decl.secrets)], + else: [] + + enforcement = + cond do + decl.enforcement != "none" and not job.hardened? -> + [re001_enforcement(job, deed_file, decl.enforcement)] + + # Claiming block while the hardener only audits is a false attestation too. + decl.enforcement == "harden-runner-block" and job.harden_policy != "block" -> + [re001_enforcement(job, deed_file, decl.enforcement)] + + true -> + [] + end + + dynamic ++ drift ++ enforcement + end + end) + + # Every declaration must still describe a local job that reads a real secret. + live = MapSet.new(jobs, &{&1.workflow, &1.id}) - # Comments are not runner configuration. Preserve physical line numbers - # so existing findings are not reported against a newly added line-1 header. - active_lines = - content - |> String.split("\n") - |> Enum.with_index(1) - |> Enum.reject(fn {line, _} -> String.starts_with?(String.trim_leading(line), "#") end) + stale_findings = + declared + |> Map.keys() + |> Enum.reject(&MapSet.member?(live, &1)) + |> Enum.sort() + |> Enum.map(&re001_stale(&1, deed_file)) - untrusted_events = - active_lines - |> workflow_events() - |> Enum.filter(&(&1 in @re001_untrusted_events)) + job_findings ++ stale_findings + end + # The locally executed jobs of one workflow that read a real secret, with + # what each holds and what it runs next to the credential. + defp re001_jobs(path, rel) do + # Comments are not runner configuration. Preserve physical line numbers + # so existing findings are not reported against a newly added line-1 header. + active_lines = + path + |> read_workflow() + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.reject(fn {line, _} -> String.starts_with?(String.trim_leading(line), "#") end) + + untrusted_events = active_lines - |> workflow_job_lines() - |> Enum.flat_map(fn job_lines -> - credential_lines = Enum.filter(job_lines, &credential_line?/1) - - # Reusable jobs delegate their runtime to the source workflow. A sibling's - # runner or hardener cannot establish this job's execution policy. - local_runner? = direct_job_field?(job_lines, "runs-on") - steps = job_steps(job_lines) - # A credential in the job's own `env:` is in every step's environment. - job_env_credentials = job_env_lines(job_lines) |> Enum.filter(&credential_line?/1) - - job_untrusted_events = - case job_event_gate(job_lines) do - nil -> untrusted_events - gated -> Enum.filter(untrusted_events, &(&1 in gated)) - end + |> workflow_events() + |> Enum.filter(&(&1 in @re001_untrusted_events)) + + # Secrets in the workflow's top-level `env:` are inherited by every job, + # so each job reads them too, and a declaration must list them. + workflow_env = workflow_env_lines(active_lines) + workflow_refs = secret_refs(workflow_env) + + active_lines + |> workflow_job_lines() + # Reusable jobs delegate their runtime to the source workflow. A sibling's + # runner or hardener cannot establish this job's execution policy. + |> Enum.filter(&direct_job_field?(&1, "runs-on")) + |> Enum.flat_map(fn [{header, _} | _] = job_lines -> + refs = + for {name, n} <- secret_refs(job_lines) ++ workflow_refs, + name not in @re001_ambient_secrets, + do: {name, n} + + case refs do + [] -> + [] + + [{_, first_line} | _] -> + # A line that reads a real secret, by name or by a computed key. + credential_lines = MapSet.new(refs, &elem(&1, 1)) + credential? = fn {_, n} -> MapSet.member?(credential_lines, n) end + steps = job_steps(job_lines) + # A credential in the job's or the workflow's `env:` is in every step's + # environment. + job_env_credentials = Enum.filter(job_env_lines(job_lines) ++ workflow_env, credential?) + + job_untrusted_events = + case job_event_gate(job_lines) do + nil -> untrusted_events + gated -> Enum.filter(untrusted_events, &(&1 in gated)) + end - with [first_credential | _] <- credential_lines, - true <- local_runner?, - exposures when exposures != [] <- re001_exposures(steps, job_untrusted_events), - false <- harden_runner_step?(job_lines) do # A step that both holds the credential and runs fetched or head code # hands the credential straight to that code. - shared = + shared_line = Enum.find_value(steps, fn step -> credential = - Enum.find(step, &credential_line?/1) || + Enum.find(step, credential?) || Enum.find(job_env_credentials, &(not overrides_env?(step, &1))) - credential && step_code_exposures(step) != [] && credential + credential && step_code_exposures(step) != [] && elem(credential, 1) end) - {severity, {_source, line}} = - case shared do - nil -> {:warn, first_credential} - credential -> {:high, credential} - end - [ %{ - rule: "RE001", - file: rel, - severity: severity, - line: line, - reason: - "job in #{rel} holds a credential (`secrets.*` other than GITHUB_TOKEN) " <> - "next to #{Enum.join(exposures, ", ")}" <> - if(shared, do: " in the same step", else: "") <> - ", but does not install `step-security/harden-runner` — " <> - "review outbound-egress monitoring", - action: :report, - detail: %{ - exposures: exposures, - fix: - "Move the credential into a job that runs no fetched or untrusted code " <> - "(pass data between jobs as artifacts), or add harden-runner as the " <> - "first step of this job, with egress-policy: block and an allowlist " <> - "derived from the job's actual required endpoints." - } + workflow: rel, + id: job_id(header), + line: shared_line || first_line, + secrets: MapSet.new(for {name, _} <- refs, is_binary(name), do: name), + dynamic_line: Enum.find_value(refs, fn {name, n} -> name == :dynamic && n end), + hardened?: harden_runner_step?(job_lines), + harden_policy: harden_runner_policy(steps), + exposures: re001_exposures(steps, job_untrusted_events), + shared?: shared_line != nil } ] - else - _ -> [] - end - end) + end end) end - # A reference to a secret other than the ambient GITHUB_TOKEN. Secret names - # are case-insensitive, so `secrets.github_token` is the ambient one too. - defp credential_line?({line, _}) do - ~r/\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)/ - |> Regex.scan(line, capture: :all_but_first) - |> Enum.any?(fn [name] -> String.upcase(name) not in @re001_ambient_secrets end) + # Every secret a job reads inside ${{ … }}, as {NAME, line}: the property + # form secrets.NAME and the index form secrets['NAME'] / secrets["NAME"], + # anywhere in the expression (${{ vars.X || secrets.Y }} reads Y). GitHub + # matches secret names without regard to case and stores them uppercase; the + # deed declares that stored form, so names are uppercased here. + # + # The job is scanned as one text, not line by line: a folded YAML scalar can + # split one expression across physical lines, and GitHub evaluates it whole. + # A valid workflow has no unclosed ${{, so every match is a real expression. + # + # Both scans step over string literals ('…', with '' for a quote) whole, so + # text inside one is never read: an expression ends at the first }} outside + # a literal (format('{{x}}', …) escapes braces that way), and + # ${{ 'secrets.X' }} reads nothing. Only the top-level secrets context + # counts, not a property named secrets (github.event.x.secrets.Y), and its + # name matches without regard to case, as GitHub's does (SECRETS.X reads X). + # + # Any other use of the context reads secrets whose names are not in the text + # (secrets[env.NAME], toJSON(secrets), secrets.*): it is reported as + # {:dynamic, line}, which no declaration can cover. + @expression ~r/\$\{\{((?:'(?:[^']|'')*'|[^'}]|\}(?!\}))*)\}\}/ + @secret_ref ~r/(? {{at, n}, at + byte_size(line) + 1} end) + + line_of = fn at -> + {_, n} = starts |> Enum.take_while(fn {start, _} -> start <= at end) |> List.last() + n + end + + for [_, {body_at, body_len}] <- Regex.scan(@expression, text, return: :index), + [{m_at, _} | groups] <- + Regex.scan(@secret_ref, binary_part(text, body_at, body_len), return: :index), + # A match that opens with a quote is a string literal, read past. + binary_part(text, body_at + m_at, 1) not in ["'", "\""] do + case Enum.find(groups, fn {at, _} -> at >= 0 end) do + {at, len} -> {String.upcase(binary_part(text, body_at + at, len)), line_of.(body_at + at)} + nil -> {:dynamic, line_of.(body_at + m_at)} + end + end + end + + defp job_id(header) do + [_, id] = Regex.run(~r/^\s+("[^"]+"|'[^']+'|[A-Za-z0-9_-]+):/, header) + String.trim(id, "\"") |> String.trim("'") + end + + # What the job runs next to its credential, for a finding's reason. + defp exposure_note(%{exposures: []}), do: "" + + defp exposure_note(job) do + "; it holds the credential next to #{Enum.join(job.exposures, ", ")}" <> + if(job.shared?, do: " in the same step", else: "") + end + + # Harden-runner contains a job, so a finding about its attestation is :warn. + defp re001_severity(job), do: if(job.shared? and not job.hardened?, do: :high, else: :warn) + + defp re001_undeclared(job, deed_file, deed_note) do + declare = + "or declare the job in the `(egress …)` clause of " <> + "#{deed_file || "the repo deed (_chora.deed)"}: " <> + "(job :workflow \"#{job.workflow}\" :job \"#{job.id}\" " <> + ":secrets (#{quoted_list(job.secrets)}) :endpoints () :enforcement none), " <> + "listing in :endpoints every host outside GitHub the job reaches — standards ADR-009." + + harden = + "harden-runner as the first step of this job, with egress-policy: block " <> + "and an allowlist derived from the job's actual required endpoints, " + + fix = + if job.exposures == [], + do: "Add " <> harden <> declare, + else: + "Move the credential into a job that runs no fetched or untrusted code " <> + "(pass data between jobs as artifacts), add " <> harden <> declare + + %{ + rule: "RE001", + file: job.workflow, + severity: re001_severity(job), + line: job.line, + reason: + "job `#{job.id}` in #{job.workflow} references `secrets.*` but does not install " <> + "`step-security/harden-runner` or declare its egress — review outbound-egress monitoring" <> + exposure_note(job) <> + if(deed_note, do: "; #{deed_note}", else: ""), + action: :report, + detail: %{exposures: job.exposures, fix: fix} + } + end + + defp re001_dynamic(job, deed_file, declared?) do + %{ + rule: "RE001", + file: job.workflow, + severity: re001_severity(job), + line: job.dynamic_line, + reason: + "job `#{job.id}` in #{job.workflow} reads secrets by a key computed at run time " <> + "(secrets[…], toJSON(secrets) or similar), which no egress declaration can list" <> + exposure_note(job) <> + if(declared?, do: "; its entry in #{deed_file} does not cover it", else: ""), + action: :report, + detail: %{ + exposures: job.exposures, + fix: + "Read each secret by its literal name (secrets.NAME) and declare the set, " <> + "or add harden-runner as the first step of this job with egress-policy: block — " <> + "standards ADR-009." + } + } + end + + defp re001_drift(job, deed_file, declared) do + unlisted = MapSet.difference(job.secrets, declared) + unused = MapSet.difference(declared, job.secrets) + + # An incomplete declaration must not lower the finding the job would + # draw undeclared: drift keeps the job's own severity. + %{ + rule: "RE001", + file: job.workflow, + severity: re001_severity(job), + line: job.line, + reason: + "job `#{job.id}` in #{job.workflow} no longer matches its egress declaration in " <> + "#{deed_file}: " <> + Enum.join( + Enum.reject( + [ + if(MapSet.size(unlisted) > 0, do: "reads undeclared #{quoted_list(unlisted)}"), + if(MapSet.size(unused) > 0, do: "declares unread #{quoted_list(unused)}") + ], + &is_nil/1 + ), + "; " + ) <> exposure_note(job), + action: :report, + detail: %{ + exposures: job.exposures, + fix: + "Make :secrets of (job :workflow \"#{job.workflow}\" :job \"#{job.id}\") in " <> + "#{deed_file} equal the secrets the job reads: (#{quoted_list(job.secrets)}), " <> + "and review :endpoints for the change." + } + } + end + + defp re001_enforcement(job, deed_file, enforcement) do + %{ + rule: "RE001", + file: job.workflow, + severity: re001_severity(job), + line: job.line, + reason: + "job `#{job.id}` in #{job.workflow} is declared in #{deed_file} with " <> + ":enforcement #{enforcement}, but " <> + if(job.hardened?, + do: "its harden-runner step runs `egress-policy: #{job.harden_policy}`", + else: "does not install `step-security/harden-runner`" + ) <> exposure_note(job), + action: :report, + detail: %{ + exposures: job.exposures, + fix: + if job.hardened? do + "Set `egress-policy: block` on this job's harden-runner step, or declare " <> + ":enforcement harden-runner-audit on (job :workflow \"#{job.workflow}\" " <> + ":job \"#{job.id}\") in #{deed_file}." + else + "Restore harden-runner as the first step of this job, or set :enforcement none " <> + "on (job :workflow \"#{job.workflow}\" :job \"#{job.id}\") in #{deed_file} " <> + "if nothing holds the job to its :endpoints." + end + } + } + end + + defp re001_stale({workflow, id}, deed_file) do + %{ + rule: "RE001", + file: deed_file, + severity: :warn, + reason: + "#{deed_file} declares egress for job `#{id}` in #{workflow}, but no locally run " <> + "job there reads a real secret — the attestation is stale", + action: :report, + detail: %{ + fix: + "Remove (job :workflow \"#{workflow}\" :job \"#{id}\") from the `(egress …)` clause, " <> + "or correct its :workflow / :job." + } + } end + defp quoted_list(names), do: names |> Enum.sort() |> Enum.map_join(" ", &"\"#{&1}\"") + defp re001_exposures(steps, untrusted_events) do code = Enum.flat_map(steps, &step_code_exposures/1) @@ -449,6 +719,19 @@ defmodule Hypatia.Rules.ResearchExtensions do end # The lines under the job's own `env:` key. + # The workflow's top-level `env:` (key at column 0) with its indented body. + # Every job, and every step in it, inherits these variables. + defp workflow_env_lines(active_lines) do + case Enum.drop_while(active_lines, fn {line, _} -> not Regex.match?(~r/^env:/, line) end) do + [env_line | rest] -> + body = Enum.take_while(rest, fn {line, _} -> line == "" or indent_of(line) > 0 end) + [env_line | body] + + [] -> + [] + end + end + defp job_env_lines(job_lines) do with field_indent when is_integer(field_indent) <- job_field_indent(job_lines), index when is_integer(index) <- @@ -683,8 +966,30 @@ defmodule Hypatia.Rules.ResearchExtensions do |> Enum.min(fn -> nil end) end + # The egress-policy of the job's harden-runner step, block or flow `with:`; + # harden-runner defaults to audit when none is set. nil without the step. + defp harden_runner_policy(steps) do + case Enum.find(steps, fn step -> + Enum.any?(step, fn {line, _} -> harden_runner_uses?(line) end) + end) do + nil -> + nil + + step -> + Enum.find_value(step, "audit", fn {line, _} -> + case Regex.run(~r/(?:^|[\s{,])egress-policy:\s*["']?([A-Za-z-]+)/, line) do + [_, policy] -> policy + nil -> nil + end + end) + end + end + + # GitHub resolves an action's owner/repo without regard to case + # (Step-Security/Harden-Runner@v2 installs the same action); the `uses:` + # key itself is case-sensitive YAML. defp harden_runner_uses?(line) do - Regex.match?(~r/^\s*(?:-\s+)?uses:\s*["']?step-security\/harden-runner@/, line) + Regex.match?(~r/^\s*(?:-\s+)?uses:\s*["']?(?i:step-security\/harden-runner)@/, line) end defp block_scalar?(line), do: Regex.match?(~r/:\s*[>|][0-9+-]*\s*(?:#.*)?$/, line) diff --git a/test/deed_test.exs b/test/deed_test.exs new file mode 100644 index 00000000..2382eb3e --- /dev/null +++ b/test/deed_test.exs @@ -0,0 +1,269 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) + +defmodule Hypatia.DeedTest do + use ExUnit.Case, async: true + + alias Hypatia.Deed + + @fixtures Path.expand("fixtures/deed_egress", __DIR__) + @tmp_dir System.tmp_dir!() + + defp repo_with_deed(name, text) do + repo = Path.join(@tmp_dir, "deed_test_#{System.unique_integer([:positive])}") + # unique_integer restarts with each VM, so clear what an earlier run left. + File.rm_rf!(repo) + File.mkdir_p!(repo) + File.write!(Path.join(repo, name), text) + repo + end + + defp repo_with_fixture(sub, name) do + repo_with_deed(name, File.read!(Path.join([@fixtures, sub, name]))) + end + + describe "parse/1" do + test "reads fields, clauses and every value form" do + text = """ + ;; SPDX-License-Identifier: CC-BY-SA-4.0 + ; a comment + (repo-deed :schema-version "1.0.0" :canonical-name "x" + :repo-uuid #u5"github.com/o/x" :beholding-chora #u8"estate:chora" + (status :present #t :ended #f :count -7 :tags ('a "b" (c)) :phase active + :note "say \\"hi\\"\\n")) + """ + + assert {:ok, {"repo-deed", items}} = Deed.parse(text) + assert {:field, "repo-uuid", {:uuid5, "github.com/o/x"}} in items + assert {:field, "beholding-chora", {:uuid8, "estate:chora"}} in items + assert {:clause, "status", status} = List.last(items) + + assert status == [ + {:field, "present", {:boolean, true}}, + {:field, "ended", {:boolean, false}}, + {:field, "count", {:integer, -7}}, + {:field, "tags", + {:list, [{:quoted, {:symbol, "a"}}, {:string, "b"}, {:list, [{:symbol, "c"}]}]}}, + {:field, "phase", {:symbol, "active"}}, + {:field, "note", {:string, "say \"hi\"\n"}} + ] + end + + test "rejects what it cannot read" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n" + assert {:error, _} = Deed.parse("(repo-deed :schema-version \"1.0.0\")") + assert {:error, _} = Deed.parse(hdr <> "(repo-deed :schema-version \"1.0.0\"") + assert {:error, _} = Deed.parse(hdr <> "(repo-deed :x \"\\r\")") + assert {:error, _} = Deed.parse(hdr <> "(widget-deed :schema-version \"1.0.0\")") + assert {:error, _} = Deed.parse(hdr <> "(repo-deed :x \"1\") (repo-deed :x \"2\")") + end + + test "enforces :schema-version exactly once, as a string" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n" + assert {:error, msg} = Deed.parse(hdr <> "(repo-deed :canonical-name \"x\")") + assert msg =~ "found 0" + assert {:error, _} = Deed.parse(hdr <> "(repo-deed :schema-version 1)") + + assert {:error, msg} = + Deed.parse(hdr <> "(repo-deed :schema-version \"1\" :schema-version \"2\")") + + assert msg =~ "found 2" + end + + test "requires the separators the grammar requires" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n" + ok = ~s|(repo-deed :schema-version "1.0.0" (egress (job :secrets ("A" "B"))))| + assert {:ok, _} = Deed.parse(hdr <> ok) + # Codex's example: no separators anywhere, and no :schema-version. + assert {:error, _} = Deed.parse(hdr <> ~s|(repo-deed(egress(job :secrets("X"))))|) + # Each missing separator on its own. + assert {:error, _} = Deed.parse(hdr <> ~s|(repo-deed:schema-version "1.0.0")|) + assert {:error, _} = Deed.parse(hdr <> ~s|(repo-deed :schema-version"1.0.0")|) + assert {:error, _} = Deed.parse(hdr <> ~s|(repo-deed :schema-version "1.0.0"(egress))|) + assert {:error, _} = Deed.parse(hdr <> ~s|(repo-deed :schema-version "1.0.0" :a ("A""B"))|) + assert {:error, _} = Deed.parse(hdr <> ~s|(repo-deed :schema-version "1.0.0" :a #tx)|) + end + + # Each case below is a class the differential run against deed.abnf + # (apg-js) found the earlier reader accepting. + test "strings hold no raw control characters" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n(repo-deed :schema-version \"1.0.0\" " + assert {:ok, _} = Deed.parse(hdr <> ~s|:a "é © \u0085 \\n \\t")|) + + for c <- ["\n", "\r", "\t", <<1>>, <<0x7F>>] do + assert {:error, msg} = Deed.parse(hdr <> ~s|:a "x#{c}y")|) + assert msg =~ "raw control character" + end + end + + test "separators are SP, CRLF, LF and comments that end in a line-end" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n" + + assert {:ok, _} = + Deed.parse(hdr <> "(repo-deed\r\n:schema-version \"1\" ; ok\r\n)\n; end\n") + + assert {:error, _} = Deed.parse(hdr <> "(repo-deed\r:schema-version \"1\")") + assert {:error, _} = Deed.parse(hdr <> "(repo-deed\t:schema-version \"1\")") + assert {:error, _} = Deed.parse(hdr <> "(repo-deed :schema-version \"1\" ;\tx\n)") + # A comment must end in a line-end, even the last one. + assert {:error, _} = Deed.parse(hdr <> "(repo-deed :schema-version \"1\")\n; end") + end + + test "SPDX header lines are text-chars with a non-blank payload" do + form = "(repo-deed :schema-version \"1\")" + assert {:ok, _} = Deed.parse(";; SPDX-FileCopyrightText: © 2026 x\r\n" <> form) + assert {:error, _} = Deed.parse(";; SPDX- \n" <> form) + assert {:error, _} = Deed.parse(";; SPDX-a\tb\n" <> form) + assert {:error, _} = Deed.parse(";; SPDX-License-Identifier: MPL-2.0" <> form) + end + + test "a list may open with a separator (deed.abnf v1.2.1)" do + # As rsr-template-repo_chora.deed writes them: `:purpose (` then a newline. + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n(repo-deed :schema-version \"1\" " + assert {:ok, _} = Deed.parse(hdr <> ~s|:a () :b ( ) :c ("x" ) :d ("x"\n"y"))|) + assert {:ok, _} = Deed.parse(hdr <> ~s|:secrets (\n "DEPLOY_KEY"\n ; c\n "X"))|) + assert {:error, _} = Deed.parse(hdr <> ~s|:secrets (\t"DEPLOY_KEY"))|) + assert {:error, _} = Deed.parse(hdr <> ~s|:secrets ("A""B"))|) + end + + test "a #u8 body is domain:name with a printable ASCII domain" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n(repo-deed :schema-version \"1\" " + assert {:ok, _} = Deed.parse(hdr <> ~s|:id #u8"gv-clade-index:github.com/x/y:z")|) + assert {:error, _} = Deed.parse(hdr <> ~s|:id #u8"no-colon")|) + assert {:error, _} = Deed.parse(hdr <> ~s|:id #u8":empty-domain")|) + assert {:error, _} = Deed.parse(hdr <> ~s|:id #u8"dé:x")|) + end + + test "a field's value is never a boolean spelled as a word, as the gate rules" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n(repo-deed :schema-version \"1\" " + assert {:ok, _} = Deed.parse(hdr <> ":a #t :b truthy :c (true no))") + + for word <- ~w(true false yes no) do + assert {:error, msg} = Deed.parse(hdr <> ":enabled #{word})") + assert msg =~ "write #t or #f" + end + end + + test "quotes only a symbol or a list, with nothing between" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n(repo-deed :schema-version \"1.0.0\" " + assert {:ok, _} = Deed.parse(hdr <> ~s|:a 'sym :b '("x" y))|) + assert {:error, _} = Deed.parse(hdr <> ~s|:note '"text")|) + assert {:error, _} = Deed.parse(hdr <> ~s|:n '7)|) + assert {:error, _} = Deed.parse(hdr <> ~s|:t '#t)|) + assert {:error, _} = Deed.parse(hdr <> ~s|:a ' sym)|) + end + + test "reads the estate template's real deed" do + # Copied from standards 1-formats/deed/tools/fixtures/valid/, where deed_lint passes it. + text = File.read!(Path.join(@fixtures, "rsr-template-repo_chora.deed")) + assert {:ok, {"repo-deed", items}} = Deed.parse(text) + assert {:field, "canonical-name", {:string, "rsr-template-repo"}} in items + end + end + + describe "egress_declarations/1 against the standards fixtures" do + for name <- File.ls!(Path.expand("fixtures/deed_egress/valid", __DIR__)) do + @name name + test "accepts valid/#{name}" do + repo = repo_with_fixture("valid", @name) + assert {:ok, @name, jobs} = Deed.egress_declarations(repo) + assert is_list(jobs) + end + end + + for name <- File.ls!(Path.expand("fixtures/deed_egress/invalid", __DIR__)) do + @name name + test "rejects invalid/#{name}" do + repo = repo_with_fixture("invalid", @name) + assert {:error, @name, reason} = Deed.egress_declarations(repo) + assert is_binary(reason) + end + end + + test "takes :policy at most once, before or after the jobs" do + hdr = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n(repo-deed :schema-version \"1.0.0\" " + + job = + ~s|(job :workflow ".github/workflows/a.yml" :job "a" | <> + ~s|:secrets ("A") :endpoints () :enforcement none)| + + repo = repo_with_deed("x_chora.deed", hdr <> ~s|(egress #{job} :policy "a"))|) + assert {:ok, _, [_]} = Deed.egress_declarations(repo) + + repo = repo_with_deed("x_chora.deed", hdr <> ~s|(egress :policy "a" :policy "b" #{job}))|) + assert {:error, _, "(egress …) repeats :policy"} = Deed.egress_declarations(repo) + end + + test "reads the template's live deed and its three declared jobs" do + repo = Path.join(@fixtures, "template-main") + + assert {:ok, "rsr-template-repo_chora.deed", jobs} = Deed.egress_declarations(repo) + + assert Enum.map(jobs, & &1.job) == ["notify", "sonarqube", "trigger-boj"] + end + + test "returns the declared jobs with their secrets" do + repo = repo_with_fixture("valid", "template-pair_chora.deed") + + assert {:ok, _, [notify, sonar]} = Deed.egress_declarations(repo) + + assert notify == %{ + workflow: ".github/workflows/push-email-notify.yml", + job: "notify", + secrets: MapSet.new(~w(SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS)), + enforcement: "none" + } + + assert sonar.secrets == MapSet.new(["SONAR_TOKEN"]) + end + end + + describe "repo_deed_path/1" do + test "no deed, one deed, two deeds" do + repo = Path.join(@tmp_dir, "deed_none_#{System.unique_integer([:positive])}") + File.rm_rf!(repo) + File.mkdir_p!(repo) + assert Deed.repo_deed_path(repo) == :none + assert Deed.egress_declarations(repo) == :none + + File.write!(Path.join(repo, "estate_chora.deed"), "") + assert Deed.repo_deed_path(repo) == :none + + File.write!(Path.join(repo, "a_chora.deed"), "") + assert {:ok, _} = Deed.repo_deed_path(repo) + + File.write!(Path.join(repo, "b_chora.deed"), "") + assert {:error, _} = Deed.repo_deed_path(repo) + end + + test "the stem is deed.abnf's: letters, digits, '-', '.', '_', at least one" do + for {name, ok?} <- [ + {"my.project-x_chora.deed", true}, + {"bad name_chora.deed", false}, + {"_chora.deed", false} + ] do + repo = Path.join(@tmp_dir, "deed_stem_#{System.unique_integer([:positive])}") + File.rm_rf!(repo) + File.mkdir_p!(repo) + File.write!(Path.join(repo, name), "") + + if ok?, + do: assert({:ok, _} = Deed.repo_deed_path(repo), name), + else: + assert({:error, "illegal deed filename stem" <> _} = Deed.repo_deed_path(repo), name) + + File.rm_rf!(repo) + end + end + + test "a _chora.deed must be a repo-deed" do + repo = + repo_with_deed( + "x_chora.deed", + ";; SPDX-License-Identifier: CC-BY-SA-4.0\n(praxis-deed :schema-version \"1\")\n" + ) + + assert {:error, _, "doc-head/filename mismatch" <> _} = Deed.egress_declarations(repo) + end + end +end diff --git a/test/fixtures/deed_egress/README.adoc b/test/fixtures/deed_egress/README.adoc new file mode 100644 index 00000000..acdf635c --- /dev/null +++ b/test/fixtures/deed_egress/README.adoc @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += Egress clause fixtures + +Copied verbatim from `metadatastician/standards`: `valid/` and `invalid/` from +`1-formats/deed/tools/fixtures/egress/` (ADR-009), and `rsr-template-repo_chora.deed` +from `fixtures/valid/` there. `test/deed_test.exs` +asserts that `Hypatia.Deed` accepts and rejects exactly what the standards +checker `deed_lint.js --egress` does. Re-copy them when that directory changes; +do not edit them here. + +`template-main/rsr-template-repo_chora.deed` is the estate template's live deed, +copied from `hyperpolymath/rsr-template-repo` main at `ad478ff`, with its +`(egress …)` clause for three jobs. It is the worked model every repo deed is +written from, so the reader must keep accepting it. diff --git a/test/fixtures/deed_egress/invalid/bad-host_chora.deed b/test/fixtures/deed_egress/invalid/bad-host_chora.deed new file mode 100644 index 00000000..b998fcca --- /dev/null +++ b/test/fixtures/deed_egress/invalid/bad-host_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ("https://h.example") :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/bad-port_chora.deed b/test/fixtures/deed_egress/invalid/bad-port_chora.deed new file mode 100644 index 00000000..d788365c --- /dev/null +++ b/test/fixtures/deed_egress/invalid/bad-port_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ("h.example:70000") :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/bad-var_chora.deed b/test/fixtures/deed_egress/invalid/bad-var_chora.deed new file mode 100644 index 00000000..e8659885 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/bad-var_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ("var:1BAD") :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/duplicate-job_chora.deed b/test/fixtures/deed_egress/invalid/duplicate-job_chora.deed new file mode 100644 index 00000000..4de370e0 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/duplicate-job_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints () :enforcement none) (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/duplicate-secret_chora.deed b/test/fixtures/deed_egress/invalid/duplicate-secret_chora.deed new file mode 100644 index 00000000..d37522c8 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/duplicate-secret_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X" "X") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/empty-secrets_chora.deed b/test/fixtures/deed_egress/invalid/empty-secrets_chora.deed new file mode 100644 index 00000000..6f21a0ff --- /dev/null +++ b/test/fixtures/deed_egress/invalid/empty-secrets_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets () :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/enforcement-string_chora.deed b/test/fixtures/deed_egress/invalid/enforcement-string_chora.deed new file mode 100644 index 00000000..526bf910 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/enforcement-string_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints () :enforcement "none"))) diff --git a/test/fixtures/deed_egress/invalid/enforcement-unknown_chora.deed b/test/fixtures/deed_egress/invalid/enforcement-unknown_chora.deed new file mode 100644 index 00000000..1fd5bcd4 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/enforcement-unknown_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints () :enforcement firewall))) diff --git a/test/fixtures/deed_egress/invalid/github-token-lowercase_chora.deed b/test/fixtures/deed_egress/invalid/github-token-lowercase_chora.deed new file mode 100644 index 00000000..e0129523 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/github-token-lowercase_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("github_token") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/github-token_chora.deed b/test/fixtures/deed_egress/invalid/github-token_chora.deed new file mode 100644 index 00000000..4603b8c6 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/github-token_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("GITHUB_TOKEN") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/job-id_chora.deed b/test/fixtures/deed_egress/invalid/job-id_chora.deed new file mode 100644 index 00000000..3decfccc --- /dev/null +++ b/test/fixtures/deed_egress/invalid/job-id_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "1a" :secrets ("X") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/label-leading-hyphen_chora.deed b/test/fixtures/deed_egress/invalid/label-leading-hyphen_chora.deed new file mode 100644 index 00000000..bfff70a1 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/label-leading-hyphen_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ("-api.example.com") :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/label-trailing-hyphen_chora.deed b/test/fixtures/deed_egress/invalid/label-trailing-hyphen_chora.deed new file mode 100644 index 00000000..911d1a93 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/label-trailing-hyphen_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ("api-.example.com") :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/missing-enforcement_chora.deed b/test/fixtures/deed_egress/invalid/missing-enforcement_chora.deed new file mode 100644 index 00000000..21fdde56 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/missing-enforcement_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ()))) diff --git a/test/fixtures/deed_egress/invalid/nested-egress_chora.deed b/test/fixtures/deed_egress/invalid/nested-egress_chora.deed new file mode 100644 index 00000000..33a33be7 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/nested-egress_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (meta :notes "x" (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints () :enforcement none)))) diff --git a/test/fixtures/deed_egress/invalid/repeated-field_chora.deed b/test/fixtures/deed_egress/invalid/repeated-field_chora.deed new file mode 100644 index 00000000..02c18a5f --- /dev/null +++ b/test/fixtures/deed_egress/invalid/repeated-field_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :job "b" :secrets ("X") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/secret-endpoint-unlisted_chora.deed b/test/fixtures/deed_egress/invalid/secret-endpoint-unlisted_chora.deed new file mode 100644 index 00000000..54a1c4e5 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/secret-endpoint-unlisted_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ("secret:Y") :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/secret-lowercase_chora.deed b/test/fixtures/deed_egress/invalid/secret-lowercase_chora.deed new file mode 100644 index 00000000..3f2563d9 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/secret-lowercase_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("FOO" "foo") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/secrets-symbols_chora.deed b/test/fixtures/deed_egress/invalid/secrets-symbols_chora.deed new file mode 100644 index 00000000..20ab64e5 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/secrets-symbols_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets (X) :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/stray-member_chora.deed b/test/fixtures/deed_egress/invalid/stray-member_chora.deed new file mode 100644 index 00000000..0c476fbf --- /dev/null +++ b/test/fixtures/deed_egress/invalid/stray-member_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (jobs))) diff --git a/test/fixtures/deed_egress/invalid/two-clauses_chora.deed b/test/fixtures/deed_egress/invalid/two-clauses_chora.deed new file mode 100644 index 00000000..4ab42f01 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/two-clauses_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress) (egress)) diff --git a/test/fixtures/deed_egress/invalid/unknown-field_chora.deed b/test/fixtures/deed_egress/invalid/unknown-field_chora.deed new file mode 100644 index 00000000..700e8c98 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/unknown-field_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints () :enforcement none :owner "me"))) diff --git a/test/fixtures/deed_egress/invalid/var-lowercase_chora.deed b/test/fixtures/deed_egress/invalid/var-lowercase_chora.deed new file mode 100644 index 00000000..5965b0a6 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/var-lowercase_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a.yml" :job "a" :secrets ("X") :endpoints ("var:boj_url") :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/workflow-dot-start_chora.deed b/test/fixtures/deed_egress/invalid/workflow-dot-start_chora.deed new file mode 100644 index 00000000..a63bfa82 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/workflow-dot-start_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/.foo.yml" :job "a" :secrets ("X") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/workflow-double-dot_chora.deed b/test/fixtures/deed_egress/invalid/workflow-double-dot_chora.deed new file mode 100644 index 00000000..1bd0a573 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/workflow-double-dot_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/a..yml" :job "a" :secrets ("X") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/invalid/workflow-path_chora.deed b/test/fixtures/deed_egress/invalid/workflow-path_chora.deed new file mode 100644 index 00000000..3540d0e7 --- /dev/null +++ b/test/fixtures/deed_egress/invalid/workflow-path_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow "ci.yml" :job "a" :secrets ("X") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/rsr-template-repo_chora.deed b/test/fixtures/deed_egress/rsr-template-repo_chora.deed new file mode 100644 index 00000000..92a54c4f --- /dev/null +++ b/test/fixtures/deed_egress/rsr-template-repo_chora.deed @@ -0,0 +1,119 @@ +;; SPDX-License-Identifier: MPL-2.0 +(repo-deed + :schema-version "1.0.0" + :canonical-name "rsr-template-repo" + :beholding-chora #u5"estate/chora" + + :repo-uuid #u5"github.com/hyperpolymath/rsr-template-repo" + + (identity :primary-forge github + :owner "hyperpolymath" + :prefixed-name "rm-rsr-template-repo") + + (clade :primary rm + :primary-name "Repo Management & Tooling" + :secondary () + :assigned "2026-03-16" + :rationale "Repo Management & Tooling (`rm`): the core value proposition is scaffolding repositories — CI/CD, AI manifests, ABI/FFI seams, governance — that other projects are instantiated from. `gv` was previously claimed as a secondary and is dropped: the template SHIPS governance files, but shipping them is not being a governance project. That authority lives in `standards` and `metadatastician-governance`. The inherited [\"gv\"] propagated into every repo created from this template, where it was even less meant.") + + (forges :github "hyperpolymath/rsr-template-repo" + :gitlab "hyperpolymath/rsr-template-repo" + :bitbucket "hyperpolymath/rsr-template-repo") + + (lineage :type standalone + :parent "" + :born "2026-03-16" + :previous-names () + :instantiated-from "") + + (status :phase active + :since "2026-03-16" + :present #t + :aliases () + :merged-into "" + :superseded-by "" + :successors () + :ended "" + (history + (entry :phase active + :since "2026-03-16" + :note "the estate's canonical repository template; in production use") + )) + + (meta + :version "0.1.0" + :last-updated "2026-04-11" + :type library + :languages () + :license MPL-2.0 + :author "Jonathan D.A. Jewell (hyperpolymath)" + :build-tool just + :container-runtime podman + :ci-platform github-actions + :package-manager guix + :scoping-first #t + :execution-order "axis-1 > axis-2 > axis-3" + :axis-1 "must > intend > like" + :axis-2 "corrective > adaptive > perfective" + :axis-3 "systems > compliance > effects" + (scoping + :sources "README, roadmap, status docs, maintenance checklist, CI/security docs" + :marker-scan "TODO/FIXME/XXX/HACK/STUB/PARTIAL" + :idris-unsound-scan "believe_me/assert_total") + :corrective-first #t + :adaptive-second #t + :adaptive-focus "scope-change reconciliation, stale-reference removal, obsolete-work culling" + :perfective-third #t + :perfective-source "axis-1 honest state after corrective/adaptive updates" + (axis-3 + :audit-focus "systems in place, documentation explains actual state, safety/security accounted for, observed effects reviewed" + :compliance-focus "seams/compromises/exception register, bounded exceptions, anti-drift checks" + :drift-risk-example "single exception broadening into policy violation (e.g. ->TypeScript spread)" + :effects-evidence "benchmark execution/results and maintainer status dialogue/review")) + + (ecosystem + :project "rsr-template-repo" + :ecosystem "hyperpolymath" + :position-type "repository-template" + :purpose "Canonical RSR-compliant repository template: scaffolding (CI/CD, AI manifests, ABI/FFI standards, container ecosystem, governance) that new hyperpolymath projects are instantiated from." + :not ("a project in its own right" "Scaffoldia (the full-featured repo designer)" "standards (the canon source this template operationalises)") + :pipeline-position "foundation" + :chain "standards → rsr-template-repo → (every estate repo)" + :pipeline-notes "rsr-template-repo turns the RSR standard into runnable scaffolding. New repos are created from it via `just repo-init`, which substitutes the {{PLACEHOLDER}} tokens." + :coordination standards + (related :name "standards" :relationship standard-source :notes "Defines the RSR standard, contractile canon, and policies that this template operationalises.") + (related :name "stapeln" :relationship build-tooling :notes "Layer-based container build system; the template ships stapeln.toml scaffolding.") + (related :name "selur-compose" :relationship build-tooling :notes "Service composition; the template ships selur-compose.toml scaffolding.") + (related :name "k9-svc" :relationship validation-tooling :notes "Runs the self-validating k9.ncl checks (.machine_readable/self-validating/).") + (related :name "cerro-torre" :relationship signing-tooling :notes "Container/image signing provider referenced by the container scaffolding.") + (related :name "svalinn" :relationship verification-tooling :notes "Supply-chain verification referenced by the container scaffolding.") + (related :name "vordr" :relationship verification-tooling :notes "Build/artifact verification referenced by the container scaffolding.")) + + (agentic + :version "0.1.0" :last-updated "2026-04-11" + (permissions :source #t + :tests #t + :docs #t + :config #t + :create-files #t) + (integrity :fail-closed #t + :require-evidence-per-step #t + :allow-silent-skip #f + :require-rerun-after-fix #t + :release-claim-requires-hard-pass #t) + (methodology :instructions-dir ".machine_readable/bot_directives/" :default-mode hybrid)) + + (neurosym + :version "0.1.0" :last-updated "2026-04-11" + (hypatia :scan-enabled #t + :scan-depth standard + :report-format "logtalk")) + + (playbook + :version "0.1.0" :last-updated "2026-04-11" + (skeleton :version "1.0" + :last-updated "2026-04-30" + :authority-allowlist ".machine_readable/root-allow.txt" + :enforcement-workflow ".github/workflows/estate-rules.yml") + (deployment) (incident-response) (release-process) (docs-format) (maintenance-operations)) +) diff --git a/test/fixtures/deed_egress/template-main/rsr-template-repo_chora.deed b/test/fixtures/deed_egress/template-main/rsr-template-repo_chora.deed new file mode 100644 index 00000000..9fe0104e --- /dev/null +++ b/test/fixtures/deed_egress/template-main/rsr-template-repo_chora.deed @@ -0,0 +1,1114 @@ +;; SPDX-License-Identifier: MPL-2.0 +; +; The repo deed of rsr-template-repo: one-deed-per-repo doctrine (standards#837, +; owner ruling 2026-09-19, Option A). Folds the former ply manifest tree +; (82 per-directory manifests) and the family-7 AI allocation manifest +; into deed clauses. +; The ply, manifest and canon clauses come from the #837 pilot emitter. The +; identity, clade, forges, lineage, status, maturity, ecosystem, meta and +; agentic clauses carry the former CLADE, META, ECOSYSTEM, AGENTIC and STATE +; records (#209), translated field for field under the standards mapping +; specs (1-formats/deed/mappings/). STATE's journal is docs/status/ROADMAP.adoc. +; The anchor, neurosym, playbook, ensaid, bot-directives, integrations, +; policies and rsr-profile clauses at the end carry every other record this +; repo used to keep under .machine_readable/: the deed is the one +; machine-readable description of the repo. scripts/deed-field.sh reads any +; field; scripts/deed-clause.sh is the one writer. +(repo-deed + :schema-version "1.0.0" + :canonical-name "rsr-template-repo" + :beholding-chora #u5"estate/chora" + :repo-uuid #u5"github.com/hyperpolymath/rsr-template-repo" + ; The canon pin: which release of hyperpolymath/standards canon.lock this + ; repo implements. It is the ONLY canon pin in this deed (deed_canon + ; refuses zero or two); the (rsr-profile) clause below declares + ; capabilities and carries no pin. It moved here so the pin could follow a + ; canon release (#215). + ; Read by: dogfood-gate.yml `Canon lockstep` (reverse direction), + ; standards scripts/check-canon-lockstep.sh assertion 3 (forward), and + ; build/just/repo-init.just (writes the child's (provenance) clause at mint). Every hash MUST + ; equal canon.lock [canon.artifacts]; canon.lock [canon.lockstep].order is + ; spine-adopts-then-canon-releases, so this clause moves FIRST. + (canon + :version "2.1.3" + :criteria-sha256 "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a" + :gates-sha256 "2f31f46d4753432e0aa8c9c28f339e18e062c98dc6e6ef4ecbb76993cf37638b" + :lockstep-since "2026-09-17" + ; A canon change this repo cannot satisfy is a change made in the WRONG + ; ORDER, not a rejected change: land the spine's adoption first, then the + ; canon change is a one-line bump. + :order "spine-adopts-then-canon-releases") + ; >>> repo-identity (repo-init replaces this block at mint) + ; The template's OWN identity. `just repo-init` swaps everything between + ; these two markers for build/templates/identity.deed.in, filled for the + ; new repo. Before that swap existed every minted repo inherited this + ; block's uuid and canonical-name and told arriving agents it WAS the + ; template (corrected in chronicles-of-slavia and scaffoldia 2026-07-16). + ; The uuid is not stored: it is uuid5(URL namespace, the header + ; :repo-uuid name). Only that typed name is written down; a hex form here + ; fails ADR-008's strict v8 gate. + ; Identity, clade, forges, lineage, status: mapping clade-to-repo-deed. + (identity + :primary-forge github + :owner "hyperpolymath" + :prefixed-name "rm-rsr-template-repo") + (clade + :primary rm + :primary-name "Repo Management & Tooling" + :secondary () + :assigned "2026-03-16" + :rationale "Repo Management & Tooling (`rm`): the core value proposition is scaffolding repositories — CI/CD, AI manifests, ABI/FFI seams, governance — that other projects are instantiated from. `gv` was previously claimed as a secondary and is dropped: the template SHIPS governance files, but shipping them is not being a governance project. That authority lives in `standards` and `metadatastician-governance`. The inherited [\"gv\"] propagated into every repo created from this template, where it was even less meant.") + (forges + :github "hyperpolymath/rsr-template-repo" + :gitlab "hyperpolymath/rsr-template-repo" + :bitbucket "hyperpolymath/rsr-template-repo") + (lineage + :type standalone + :parent "" + :born "2026-03-16" + :previous-names ()) + (status + :phase active + :since "2026-03-16" + :present #t + :aliases () + :merged-into "" + :superseded-by "" + :successors () + :ended "" + (history + (entry :phase active :since "2026-03-16" :note "the estate's canonical repository template; in production use"))) + ; Maturity has its own clause (owner decision 2026-10-02; standards + ; 1-formats/deed/vocabulary/maturity.adoc). No :since: the date the + ; template reached production was never recorded. Milestones, next actions + ; and blockers live in docs/status/ROADMAP.adoc. + (maturity + :level production) + ; Ecosystem: mapping meta-ecosystem-to-repo-deed. + (ecosystem + :project "rsr-template-repo" + :ecosystem "hyperpolymath" + :position-type "repository-template" + :purpose "Canonical RSR-compliant repository template: scaffolding (CI/CD, AI manifests, ABI/FFI standards, container ecosystem, governance) that new hyperpolymath projects are instantiated from." + :not ("a project in its own right" + "Scaffoldia (the full-featured repo designer)" + "standards (the canon source this template operationalises)") + :pipeline-position "foundation" + :chain "standards → rsr-template-repo → (every estate repo)" + :pipeline-notes "rsr-template-repo turns the RSR standard into runnable scaffolding. New repos are created from it via `just repo-init`, which substitutes the {{PLACEHOLDER}} tokens." + :coordination standards + (related :name "standards" :relationship standard-source :notes "Defines the RSR standard, contractile canon, and policies that this template operationalises.") + (related :name "stapeln" :relationship build-tooling :notes "Layer-based container build system; the template ships stapeln.toml scaffolding.") + (related :name "selur-compose" :relationship build-tooling :notes "Service composition; the template ships selur-compose.toml scaffolding.") + (related :name "k9-svc" :relationship validation-tooling :notes "Runs the self-validating k9.ncl checks (.machine_readable/self-validating/).") + (related :name "cerro-torre" :relationship signing-tooling :notes "Container/image signing provider referenced by the container scaffolding.") + (related :name "svalinn" :relationship verification-tooling :notes "Supply-chain verification referenced by the container scaffolding.") + (related :name "vordr" :relationship verification-tooling :notes "Build/artifact verification referenced by the container scaffolding.")) + ; <<< repo-identity + ; Meta: mapping meta-ecosystem-to-repo-deed. A minted repo keeps this + ; clause (self-name pass applied) and edits it as its own. + (meta + :version "0.1.0" + :last-updated "2026-04-11" + :type library + :languages () + :license MPL-2.0 + :author "Jonathan D.A. Jewell (hyperpolymath)" + :build-tool just + :container-runtime podman + :ci-platform github-actions + :package-manager guix + :scoping-first #t + :execution-order "axis-1 > axis-2 > axis-3" + :axis-1 "must > intend > like" + :axis-2 "corrective > adaptive > perfective" + :axis-3 "systems > compliance > effects" + (scoping + :sources "README, roadmap, status docs, maintenance checklist, CI/security docs" + :marker-scan "TODO/FIXME/XXX/HACK/STUB/PARTIAL" + :idris-unsound-scan "believe_me/assert_total") + :corrective-first #t + :adaptive-second #t + :adaptive-focus "scope-change reconciliation, stale-reference removal, obsolete-work culling" + :perfective-third #t + :perfective-source "axis-1 honest state after corrective/adaptive updates" + (axis-3 + :audit-focus "systems in place, documentation explains actual state, safety/security accounted for, observed effects reviewed" + :compliance-focus "seams/compromises/exception register, bounded exceptions, anti-drift checks" + :drift-risk-example "single exception broadening into policy violation (e.g. ->TypeScript spread)" + :effects-evidence "benchmark execution/results and maintainer status dialogue/review")) + ; Agent permissions and integrity: mapping agentic-neurosym-playbook-to-repo-deed. + ; What agents must NOT do: + ; - Never use banned language patterns (believe_me, unsafeCoerce, etc.) + ; - Never commit secrets or credentials + ; - Never use banned languages (TypeScript, Python, Go, etc.) + ; - Never place state files in repository root (must be in .machine_readable/) + ; - Never relicense an existing file, and never run an automated licence + ; sweep (LICENCE-POLICY.adoc A2). New files get correct SPDX from birth. + ; - Never assume a licence. Read standards/3-practice/LICENCE-POLICY.adoc: Rule 1 + ; defaults to MPL-2.0 (code) / CC-BY-SA-4.0 (prose), but Rule 3 + ; (co-developed), Rule 4 (network-deployed services) and Rule 5 + ; (games) are AGPL-3.0-or-later, and Rule 2 names the PMPL register. + ; This clause declares WHAT agents may do; the (bot-directives) clause of + ; this deed declares HOW they work (methodology, coverage, debt, rra). + ; Hooks: on enter, read this deed, then docs/status/ROADMAP.adoc; on exit, + ; update ROADMAP.adoc and the (bot-directives (coverage)) and + ; (bot-directives (debt)) clauses with scripts/deed-clause.sh; on commit, + ; run `just validate-rsr`. + (agentic + :version "0.1.0" + :last-updated "2026-04-11" + (permissions :source #t :tests #t :docs #t :config #t :create-files #t) + (integrity + :fail-closed #t + :require-evidence-per-step #t + :allow-silent-skip #f + :require-rerun-after-fix #t + :release-claim-requires-hard-pass #t) + (methodology :instructions-clause "bot-directives" :default-mode hybrid)) + (manifest + :version "0.1.0" + :last-updated "{{CURRENT_DATE}}" + :project "[YOUR-REPO-NAME]" + :purpose "Canonical RSR (Rhodium Standard Repository) template — governance, CI/CD, machine-readable metadata, ABI/FFI seam and formal-verification scaffolding that hyperpolymath projects are instantiated from." + (agent :name CLAUDE :role "proofs, compilers, repo-local implementation, CI/CD, formal verification, Rust/Idris2/Zig") + (agent :name CHATGPT :role "prose, papers, publication review, standards docs, outreach drafts, letters") + (agent :name GEMINI :role "estate audits, cross-repo sweeps, long-context triage, pattern detection") + (agent :name VIBE :role "UI/frontend, PanLL panels, components, theming, rapid prototyping") + (policy :rules ("Do not duplicate tasks across sections. If a task needs multiple LLMs, note the handoff." "Update THIS file during sessions. Do NOT recreate per-repo TODO files.")) + (work :agent CLAUDE :task "proofs, compilers, repo-local implementation, CI/CD, formal verification, Rust/Idris2/Zig") + (work :agent CHATGPT :task "prose, papers, publication review, standards docs, outreach drafts, letters") + (work :agent GEMINI :task "estate audits, cross-repo sweeps, long-context triage, pattern detection") + (work :agent VIBE :task "UI/frontend, PanLL panels, components, theming, rapid prototyping") + ) + (ply + (directory :ply 1 :path ".github") + (directory :id "machine-readable-pillar" :ply 1 :path ".machine_readable" :description "Registry for all machine-readable metadata, policies, and internal\nautomation scripts." + (canonical-locations :ai_configs "ai/" :anchor "rsr-template-repo_chora.deed (anchor)" :compliance "compliance/" :neurosym "rsr-template-repo_chora.deed (neurosym)" :playbook "rsr-template-repo_chora.deed (playbook)" :policies "rsr-template-repo_chora.deed (policies)" :scripts "scripts/") + :invariants ("Repo metadata MUST live in the repo deed (rsr-template-repo_chora.deed) and follow the DEED grammar" "Internal automation MUST live in scripts/ subfolder") + (directory :id "ai-registry" :ply 2 :path ".machine_readable/ai" :description "Sub-registry for ai metadata.") + (directory :id "configs-registry" :ply 2 :path ".machine_readable/configs" :description "Sub-registry for configs metadata.") + (directory :ply 0 :path ".machine_readable/descriptiles" :description "This manifest declares the AI-assistant context for the descriptiles machine-readable metadata directory." + :invariants ("No duplicate files in root directory" "Single source of truth: this directory is authoritative" "No stale metadata")) + (directory :id "policies-registry" :ply 2 :path ".machine_readable/policies" :description "Sub-registry for policies metadata.") + (directory :id "automation-scripts-unit" :ply 2 :path ".machine_readable/scripts" :description "Internal automation logic for the project lifecycle, forge sync,\nverification triggers, and maintenance." + (canonical-locations :forge "forge/" :lifecycle "lifecycle/" :maintenance "maintenance/" :verification "verification/") + (directory :id "automation-unit-forge" :ply 3 :path ".machine_readable/scripts/forge" :description "Internal automation logic for project forge.") + (directory :id "automation-unit-lifecycle" :ply 3 :path ".machine_readable/scripts/lifecycle" :description "Internal automation logic for project lifecycle.") + (directory :id "automation-unit-verification" :ply 3 :path ".machine_readable/scripts/verification" :description "Internal automation logic for project verification."))) + (directory :id "container-templates" :ply 1 :path "build/container" :version "1.0.0" :description "Container templates for the stapeln container ecosystem. This directory\nprovides Podman-Chainguard-stapeln templates that are customised via\n`just container-init` or `just repo-init` during project bootstrap.\n\nAll files use {{PLACEHOLDER}} tokens that are substituted with project-\nspecific values during initialisation." :purpose "Provide a complete, security-first container deployment story for any\nRSR-compliant repository. The templates cover the full lifecycle:\nbuild, sign, verify, deploy, monitor, and govern." :overview "The stapeln container ecosystem comprises six tools:\n\nselur — Container orchestration with zero-copy IPC. Reads compose.toml.\ncerro-torre — Verified container packaging (.ctp bundles), Ed25519 signing.\nsvalinn — Policy-driven edge gateway (auth, rate limits, CORS, trust).\nvordr — Runtime monitoring (health, crashes, resources, logs).\nrokur — Secrets management (runtime injection, no baked secrets).\nk9-svc — Nickel deployment components (Kennel/Yard/Hunt trust levels)." + :context ("https://stapeln.dev/ns/v1") + (canonical-locations :build_pipeline "build/container/stapeln/ct-build.sh" :compose "build/container/stapeln/compose.toml" :compose_example "build/container/stapeln/compose.example.toml" :compose_portable "build/container/compose.yaml" :compose_portable_example "build/container/compose.example.yaml" :containerfile "build/container/Containerfile" :containerignore "build/container/.containerignore" :deployment "build/container/stapeln/deploy.k9.ncl" :entrypoint "build/container/entrypoint.sh" :gatekeeper "build/container/stapeln/.gatekeeper.yaml" :just_module "build/just/container.just" :manifest "build/container/stapeln/manifest.toml" :monitoring "build/container/stapeln/vordr.toml" :secrets_gate "build/container/stapeln/rokur.toml") + :invariants ("Base images MUST be cgr.dev/chainguard/wolfi-base or cgr.dev/chainguard/static" "Container runtime is Podman — never Docker" "Containerfile — never Dockerfile" "All images run as non-root (appuser or project-specific user)" ".ctp bundles are signed with Ed25519 via cerro-torre" "Health endpoints (/health, /ready) must always be public (no auth)") + (file-relationships + (file :name "compose.toml" :role "Orchestration" :description "selur-compose stack definition. Declares services, volumes, networks,\nand health checks. References the Containerfile for image builds and\n.gatekeeper.yaml for svalinn policy." :depends-on ("Containerfile" ".gatekeeper.yaml")) + (file :name "Containerfile" :role "Image Build (Tier A)" :description "Multi-stage OCI container build. Stage 1 compiles the application on\nwolfi-base; Stage 2 copies the binary into a minimal runtime image and\ncopies entrypoint.sh. Engine-agnostic (podman/nerdctl/docker). The\nstapeln files (.gatekeeper.yaml, manifest.toml) are referenced only as\nOCI labels and copied in only if you uncomment those COPY lines, so the\nimage has no hard dependency on tier C." :depends-on ("entrypoint.sh")) + (file :name "manifest.toml" :role "Bundle Metadata" :description "Cerro-torre .ctp bundle manifest. Describes provenance, dependencies,\nattestations, and runtime security profile. Used by `ct pack` and\n`ct verify`." :depends-on ()) + (file :name ".gatekeeper.yaml" :role "Gateway Policy" :description "Svalinn edge gateway policy. Controls authentication, rate limiting,\ncontainer trust, request validation, CORS, and audit logging." :depends-on ()) + (file :name "ct-build.sh" :role "Build Pipeline" :description "Shell script implementing the 5-stage pipeline: build (Podman),\npack (cerro-torre .ctp), sign (Ed25519), verify, push (optional).\nDegrades gracefully when cerro-torre tools are not installed." :depends-on ("Containerfile" "manifest.toml")) + (file :name "entrypoint.sh" :role "Container Entrypoint" :description "Startup script with signal handling (SIGTERM, SIGINT), logging, and\nexec into the main application process." :depends-on ()) + (file :name "vordr.toml" :role "Runtime Monitoring" :description "Vordr monitoring configuration. Health endpoint probing, crash\ndetection, resource thresholds, and structured log output." :depends-on ()) + (file :name "deploy.k9.ncl.in" :role "Deployment Component (template)" :description "k9-svc deployment component at the Hunt leash, K9 contract v1.0.0:\npedigree with capability grant, side effects and signature block;\nenvironment configs, container config, rolling deployment strategy,\ndeploy scripts and Just recipes. `just container-init` renders it to\ndeploy.k9.ncl once no placeholder token is left." :depends-on ("compose.toml" "ct-build.sh")) + (file :name "compose.example.toml" :role "Example" :description "Fully-commented multi-service example (Rust API + Elixir worker +\nsvalinn gateway). Copy to compose.toml and customise." :depends-on ()) + )) + (directory :id "docs-pillar" :ply 1 :path "docs" :description "Technical documentation hub. The root contains high-level orientation\n(README, Quickstart, State-Visualizer). Specialized tracks live in\nsubdirectories." + (canonical-locations :architecture "architecture/" :attribution "attribution/" :decisions "decisions/" :developer "developer/" :governance "governance/" :legal "legal/" :practice "practice/" :quickstart "QUICKSTART.adoc" :reports "reports/" :standards "standards/" :state_visualizer "STATE-VISUALIZER.adoc" :theory "theory/" :whitepapers "whitepapers/" :wikis "wikis/") + :invariants ("Primary documentation format MUST be AsciiDoc (.adoc)" "Root docs/ MUST only contain pillar entry points") + (directory :id "architecture-track" :ply 2 :path "docs/architecture" :description "Documentation track for system architecture and threat models." + (canonical-locations :threat_model "THREAT-MODEL.adoc") + :invariants ("Visual diagrams MUST include ASCII or Mermaid representations")) + (directory :id "attribution-unit" :ply 2 :path "docs/attribution" :description "Sub-unit of the docs pillar focusing on attribution.") + (directory :id "decisions-unit" :ply 2 :path "docs/decisions" :description "Sub-unit of the docs pillar focusing on decisions.") + (directory :id "developer-unit" :ply 2 :path "docs/developer" :description "Sub-unit of the docs pillar focusing on developer.") + (directory :id "governance-pillar" :ply 1 :path "docs/governance" :description "Primary governance pillar implementing the Triaxial Software Development\nMethodology (TSDM). Contains planning, maintenance, and audit tracks." + (canonical-locations :approach "SOFTWARE-DEVELOPMENT-APPROACH.adoc" :audit "audit/" :checklist "MAINTENANCE-CHECKLIST.adoc" :crg "CRG-CRITERIA.adoc" :maintenance "maintenance/" :planning "planning/" :tsdm_spec "TSDM.adoc") + (directory :id "governance-axis-audit" :ply 2 :path "docs/governance/audit" :description "TSDM Audit track." + (directory :id "governance-unit-compliance" :ply 3 :path "docs/governance/audit/compliance" :description "TSDM compliance unit within the Audit axis.") + (directory :id "governance-unit-effects" :ply 3 :path "docs/governance/audit/effects" :description "TSDM effects unit within the Audit axis.") + (directory :id "governance-unit-systems" :ply 3 :path "docs/governance/audit/systems" :description "TSDM systems unit within the Audit axis.")) + (directory :id "governance-axis-maintenance" :ply 2 :path "docs/governance/maintenance" :description "TSDM Maintenance track." + (directory :id "governance-unit-adaptive" :ply 3 :path "docs/governance/maintenance/adaptive" :description "TSDM adaptive unit within the Maintenance axis.") + (directory :id "governance-unit-corrective" :ply 3 :path "docs/governance/maintenance/corrective" :description "TSDM corrective unit within the Maintenance axis.") + (directory :id "governance-unit-perfective" :ply 3 :path "docs/governance/maintenance/perfective" :description "TSDM perfective unit within the Maintenance axis.")) + (directory :id "governance-axis-planning" :ply 2 :path "docs/governance/planning" :description "TSDM Planning track." + (directory :id "governance-unit-could" :ply 3 :path "docs/governance/planning/could" :description "TSDM could unit within the Planning axis.") + (directory :id "governance-unit-must" :ply 3 :path "docs/governance/planning/must" :description "TSDM must unit within the Planning axis.") + (directory :id "governance-unit-should" :ply 3 :path "docs/governance/planning/should" :description "TSDM should unit within the Planning axis."))) + (directory :id "legal-track" :ply 2 :path "docs/legal" :description "Sub-unit for legal and licensing documentation. Contains framework\nexhibits and archival license texts." + (canonical-locations :exhibits "exhibits/" :texts "texts/")) + (directory :id "practice-unit" :ply 2 :path "docs/practice" :description "Sub-unit of the docs pillar focusing on practice.") + (directory :id "reports-unit" :ply 2 :path "docs/reports" :description "Documentation unit for all automated and manual audit reports. Classified\nby domain." + (canonical-locations :compliance "compliance/" :maintenance "maintenance/" :performance "performance/" :quality "quality/" :security "security/") + (directory :id "report-unit-compliance" :ply 3 :path "docs/reports/compliance" :description "Specialised repository for compliance findings and evidence.") + (directory :id "report-unit-maintenance" :ply 3 :path "docs/reports/maintenance" :description "Maintenance reports.") + (directory :id "report-unit-performance" :ply 3 :path "docs/reports/performance" :description "Specialised repository for performance findings and evidence.") + (directory :id "report-unit-quality" :ply 3 :path "docs/reports/quality" :description "Specialised repository for quality findings and evidence.") + (directory :id "report-unit-security" :ply 3 :path "docs/reports/security" :description "Specialised repository for security findings and evidence.")) + (directory :id "standards-unit" :ply 2 :path "docs/standards" :description "Standards unit for high-rigor verification.") + (directory :id "theory-track" :ply 2 :path "docs/theory" :description "Documentation track for domain-specific theory and research foundations.\nCategorised by discipline." + (canonical-locations :computing "computing/" :formalisms "formalisms/" :mathematics "mathematics/" :ontologies "ontologies/" :other "other/" :socio_technical "socio-technical/") + :invariants ("Theoretical claims MUST reference established academic or technical formalisms") + (directory :id "theory-unit-computing" :ply 3 :path "docs/theory/computing" :description "Theoretical foundation for computing.") + (directory :id "theory-unit-formalisms" :ply 3 :path "docs/theory/formalisms" :description "Theoretical foundation for formalisms.") + (directory :id "theory-unit-mathematics" :ply 3 :path "docs/theory/mathematics" :description "Theoretical foundation for mathematics.") + (directory :id "theory-unit-ontologies" :ply 3 :path "docs/theory/ontologies" :description "Theoretical foundation for ontologies.") + (directory :id "theory-unit-other" :ply 3 :path "docs/theory/other" :description "Theoretical foundation for other.") + (directory :id "theory-unit-socio-technical" :ply 3 :path "docs/theory/socio-technical" :description "Theoretical foundation for socio technical.")) + (directory :id "whitepapers-track" :ply 2 :path "docs/whitepapers" :description "Unit for strategic publications and whitepapers. Categorised by target\naudience: Academic, Industry, and Outreach." + (canonical-locations :academic "academic/" :industry "industry/" :outreach "outreach/") + :invariants ("Each sub-track MUST have a clear audience definition in its README") + (directory :id "academic-unit" :ply 3 :path "docs/whitepapers/academic" :description "Academic logic at level 3.") + (directory :id "industry-unit" :ply 3 :path "docs/whitepapers/industry" :description "Industry logic at level 3.") + (directory :id "whitepapers-track-outreach" :ply 3 :path "docs/whitepapers/outreach" :description "Documentation track for outreach, education, and general-audience\nengagement. Focuses on accessibility and high-level conceptual clarity." + :invariants ("Language MUST be accessible to non-technical audiences" "Avoid deep technical jargon without providing clear definitions"))) + (directory :id "wikis-track" :ply 2 :path "docs/wikis" :description "Long-form collaborative documentation and project knowledge base.\nThis directory mirrors the content structure of the project wiki." + :invariants ("Primary wiki format MUST be Markdown (.md) — owner ruling 2026-09-19: wikis are the .md home (berrywiki); everything outside wikis stays .adoc"))) + (directory :ply 1 :path "examples") + (directory :id "features-pillar" :ply 1 :path "features" :description "Optional project features and ecosystem integrations. Provides bootstrap\nguides for high-rigor tools (Panic-Attacker, BoJ-Server, SSGs)." + (canonical-locations :boj_server "boj-server/" :panic_attacker "panic-attacker/" :ssg "ssg/") + (directory :id "feature-unit-boj-server" :ply 2 :path "features/boj-server" :description "Bootstrap and integration logic for the boj-server ecosystem component.") + (directory :id "feature-unit-panic-attacker" :ply 2 :path "features/panic-attacker" :description "Bootstrap and integration logic for the panic-attacker ecosystem component.") + (directory :id "feature-unit-ssg" :ply 2 :path "features/ssg" :description "Bootstrap and integration logic for the ssg ecosystem component.")) + (directory :id "source-pillar" :ply 1 :path "src" :description "Primary source code directory. Organized by role and architectural\naspect." + (canonical-locations :aspects "aspects/" :bridges "bridges/" :contracts "contracts/" :core "core/" :definitions "definitions/" :errors "errors/" :interface "interface/") + :invariants ("Core logic MUST reside in core/" "Verified seams MUST reside in interface/" "Safety constraints MUST reside in contracts/" "Failure dictionaries MUST reside in errors/") + (directory :id "source-unit-aspects" :ply 2 :path "src/aspects" :description "Cross-cutting concerns and domain-specific aspects (Security,\nObservability, Integrity)." + (canonical-locations :integrity "integrity/" :observability "observability/" :security "security/") + (directory :id "aspect-unit-integrity" :ply 3 :path "src/aspects/integrity" :description "Implementation logic for the integrity aspect.") + (directory :id "aspect-unit-observability" :ply 3 :path "src/aspects/observability" :description "Implementation logic for the observability aspect.") + (directory :id "aspect-unit-security" :ply 3 :path "src/aspects/security" :description "Implementation logic for the security aspect.")) + (directory :id "source-unit-bridges" :ply 2 :path "src/bridges" :description "Integration logic for external systems (API, Database, RPC, etc.).") + (directory :id "source-unit-contracts" :ply 2 :path "src/contracts" :description "Contracts unit for high-rigor source code.") + (directory :id "source-unit-core" :ply 2 :path "src/core" :description "Primary application logic and core domain models.") + (directory :id "source-unit-definitions" :ply 2 :path "src/definitions" :description "Definitions unit for high-rigor source code.") + (directory :id "source-unit-errors" :ply 2 :path "src/errors" :description "Errors unit for high-rigor source code.") + (directory :id "interface-seams-unit" :ply 2 :path "src/interface" :description "Consolidated \"Verified Interface Seams\" unit. This directory unifies the\nformal specification (ABI), the bridge implementation (FFI), and the\nresulting artifacts (Generated)." + (canonical-locations :abi "abi/" :ffi "ffi/" :generated "generated/") + :invariants ("ABI MUST be Idris2 (.idr)" "FFI MUST be Zig (.zig)" "Generated artifacts MUST be C-compatible" "The 'Truth' lives in abi/; the 'Implementation' lives in ffi/") + (directory :id "abi-logic" :ply 3 :path "src/interface/Abi" :description "Specialised Level 3 logic for abi.") + (directory :id "ffi-logic" :ply 3 :path "src/interface/ffi" :description "Specialised Level 3 logic for ffi." + (directory :id "src-unit" :ply 4 :path "src/interface/ffi/src" :description "Src logic at level 4.") + (directory :id "test-unit" :ply 4 :path "src/interface/ffi/test" :description "Test logic at level 4.")) + (directory :id "generated-logic" :ply 3 :path "src/interface/generated" :description "Specialised Level 3 logic for generated." + (directory :id "abi-unit" :ply 4 :path "src/interface/generated/abi" :description "Abi logic at level 4.")))) + (directory :id "verification-pillar" :ply 1 :path "verification" :description "Primary verification pillar. Contains evidence for correctness,\nperformance, formal proofs, randomized testing, and aerospace-grade\nhigh-assurance metrics (MC/DC coverage, traceability, safety cases)." + (canonical-locations :benchmarks "benchmarks/" :coverage "coverage/" :fuzzing "fuzzing/" :proofs "proofs/" :safety_case "safety_case/" :simulations "simulations/" :tests "tests/" :traceability "traceability/") + :invariants ("Evidence MUST be reproducible and documented" "High-assurance deployments MUST satisfy traceability and safety_case requirements") + (directory :id "benches-pillar" :ply 2 :path "verification/benchmarks" :description "Benches pillar.") + (directory :id "verification-unit-coverage" :ply 2 :path "verification/coverage" :description "High-assurance verification unit for coverage. \nCritical for safety-of-life and aerospace-grade deployment standards.") + (directory :id "fuzzing-unit" :ply 2 :path "verification/fuzzing" :description "Fuzzing unit for high-rigor verification.") + (directory :id "verification-unit-proofs" :ply 2 :path "verification/proofs" :description "Sub-unit focusing on proofs.") + (directory :id "verification-unit-safety_case" :ply 2 :path "verification/safety_case" :description "High-assurance verification unit for safety case. \nCritical for safety-of-life and aerospace-grade deployment standards.") + (directory :id "simulations-unit" :ply 2 :path "verification/simulations" :description "Simulations unit for high-rigor verification.") + (directory :ply 2 :path "verification/tests") + (directory :id "verification-unit-traceability" :ply 2 :path "verification/traceability" :description "High-assurance verification unit for traceability. \nCritical for safety-of-life and aerospace-grade deployment standards.")) + ) + ; ANCHOR — the authoritative anchor: semantic authority, implementation policy + ; and the golden path. The CLAUDE.md arrival pack reads anchor/golden-path and hashes this clause (ANCHOR@…). + (anchor + :schema "hyperpolymath.anchor/1" + :repo "{{OWNER}}/{{REPO}}" + :authority "upstream-canonical" + :purpose ( + "Define canonical semantics and policy boundaries for this repository." + "Declare what downstream/satellite repos can extend but not redefine." + "Provide a stable golden path and invariant contract for release readiness." + ) + (metadata + :version "1.0.0" + :last-updated "{{CURRENT_DATE}}") + (identity + :project "{{PROJECT_NAME}}" + ; language | library | service | tool + :kind "{{PROJECT_KIND}}" + :one-sentence "{{PROJECT_PURPOSE}}" + :domain "{{PROJECT_DOMAIN}}") + (semantic-authority + :policy "canonical" + :owns ( + "Project semantics and specification" + "Invariant definitions and contractiles" + "Reference implementation behavior" + )) + (implementation-policy + :allowed ("Rust" "Idris2" "Zig" "Scheme" "Shell" "Just" "AsciiDoc" "Markdown") + :forbidden ("Node.js" "npm")) + (golden-path + :smoke-test-command ("just test" "just quality") + :success-criteria ( + "Core tests pass" + "Quality gates pass" + "No unresolved critical security findings" + )) + (satellite-policy + :must-pin-upstream #t + :must-declare-authority #t + :must-have-anchor #t + :must-have-golden-path #t) + (semantic-authority-files + :language-spec "SPECIFICATION.md" + :formal-proofs "docs/proofs/PROOFS.adoc" + :type-theory "docs/theory/THEORY.adoc" + :algorithms "docs/theory/ALGORITHMS.adoc")) + ; NEUROSYM — neurosymbolic integration metadata: the meaning of operations. + ; Configuration for Hypatia scanning and symbolic reasoning. + (neurosym + (metadata + :version "0.1.0" + :last-updated "2026-04-11") + (hypatia-config + :scan-enabled #t + ; quick | standard | deep + :scan-depth "standard" + :report-format "logtalk") + (symbolic-rules + ; Custom symbolic rules for this project + ; (rule :name "no-unsafe-ffi" :pattern "believe_me|unsafeCoerce" :severity "critical") + ) + (neural-config + ; Neural pattern detection settings + ; confidence-threshold = 0.85 + ; model = "hypatia-v2" + )) + ; PLAYBOOK — operational playbook: how permitted actions run. + ; Runbooks, incident response, deployment procedures. + (playbook + (metadata + :version "0.1.0" + :last-updated "2026-04-11") + (deployment + ; :method "gitops" ; gitops | manual | ci-triggered + ; :target "container" ; container | binary | library | wasm + ) + (incident-response + ; 1. Check the repo deed (*_chora.deed) (status) clause and docs/status/ROADMAP.adoc for current status + ; 2. Review recent commits and CI results + ; 3. Run `just validate` to check compliance + ; 4. Run `just security` to audit for vulnerabilities + ) + (release-process + ; 1. Update version in the repo deed (meta :version), Justfile + ; 2. Run `just release-preflight` (validate + quality + security + maint-hard-pass) + ; 3. Optional local permission hardening: `just perms-snapshot && just perms-lock` + ; 4. Tag and push + ; 5. Restore local permissions if needed: `just perms-restore` + ; 6. Run `just container-push` if applicable + ) + (maintenance-operations + ; Baseline audit: + ; just maint-audit + ; Hard release gate: + ; just maint-hard-pass + ; Permission audit: + ; just perms-audit + ) + (rsr-repo-skeleton + ; Canonical organisation of any RSR-derived repository. + ; Used by tooling, human onboarding, and the scheduled downstream sweep agent. + ; The 5-PR cleanup pattern (below) brings a non-conforming repository into + ; compliance with this skeleton. + ; + ; This section is the single source of truth for "what does an RSR repo look + ; like?". Other docs (TOPOLOGY, AUDIT, etc.) describe the repo at hand; + ; this describes the canonical shape that all RSR repos share. + :skeleton-version "1.0" + :last-updated "2026-04-30" + :authority-allowlist ".machine_readable/root-allow.txt" + :enforcement-workflow ".github/workflows/estate-rules.yml")) + ; eNSAID environment configuration — per-repo configuration for PanLL and + ; eNSAID-compatible tools. + ; Spec: https://github.com/hyperpolymath/standards/tree/main/1-formats/sub-specs/ensaid-config + (ensaid + :version "1.0.0" + :tool "panll" + ; Workspace mode, protection, and execution policy + (workspace + ; rhodium | gold | silver | bronze + :mode "rhodium" + ; open | guarded | locked + :protection "open" + ; live | dry-run | approval-required + :execution "live") + ; User/repo-level display and behaviour preferences + (preferences + ; high | medium | low (drift aura intensity) + :humidity "medium" + ; workspace arrangement ID + :default-arrangement "default-3-panel" + ; auto-connect panels to backends on load + :auto-connect #t) + ; Panel visibility, enablement, and isolation overrides + (panels + :version "1.0.0" + ; By default, all panels are available. Uncomment to restrict: + ; (enabled :id "valence-shell" :isolation "native" :auto-connect #t) + ; (enabled :id "editor-bridge" :isolation "native" :auto-connect #t) + ; Panels to hide for this repo context: + ; (disabled :ids ()) + ) + ; Automation Router event-driven cross-panel rules + (workflows + :version "1.0.0" + ; Example: rebuild on file save + ; (rule :name "build-on-save" + ; (trigger :event "file-changed" :pattern "src/**/*.res") + ; (condition :panel "build-dashboard" :field "watchMode" :equals #t) + ; (action :panel "build-dashboard" :message "TriggerBuild" (args :target "game")) + ; :approval "auto-fire") ; auto-fire | require-approval | approve-once | dry-run-first + ) + ; Panel clade trait and capability overrides + (clades + :version "1.0.0" + ; Example: add a custom capability to a panel clade + ; (override :id "build-dashboard" (traits :has-work-items #t) :capabilities-add ("CustomCheck")) + ) + ; Custom panel bundles for this repo's workflow + (portfolios + :version "1.0.0")) + ; Bot directives — what agents and bots read at session start and update at + ; session end. Human guide: + ; .machine_readable/bot_directives/README.adoc. + (bot-directives + ; methodology — AI agent methodology configuration + ; Declares how agents should approach work in this repository. + ; Read at session start by any AI agent (Claude, Gemini, Copilot, etc.) + ; + ; Reference: ADR-002 (methodology layer) in hyperpolymath/standards + (methodology + (metadata + :version "1.0.0" + :last-updated "{{CURRENT_DATE}}" + :spec "https://github.com/hyperpolymath/standards/blob/main/1-formats/a2ml/agentic/docs/ADR-002-methodology-layer.adoc") + ; ============================================================================ + ; MODE SELECTION + ; ============================================================================ + ; convergent: find gaps, fill them, build infrastructure (default for ops/infra) + ; divergent: find what's strongest, push it further (for research/creative) + ; hybrid: audit 20% of budget, then focus 80% on top MUSTs (default for most) + (methodology + :default-mode "hybrid" + ; Hard ceiling for ring expansion (0-3) + :ring-ceiling 2 + ; Max waves before requiring user "keep going" + :wave-cap 2 + ; Every session must ship code, not just designs + :spike-required #t + ; ============================================================================ + ; PRIORITY WEIGHTS + ; ============================================================================ + ; MUST (3x): Blocking the current work → fix immediately + ; SHOULD (2x): Degrading quality of current work → fix if in zone + ; COULD (1x): Improving quality of adjacent work → add to debt list + (priority-weights + :must 3 + :should 2 + :could 1) + ; ============================================================================ + ; CONVERGENT BUDGET (when mode = convergent or hybrid) + ; ============================================================================ + ; How to allocate effort across work types. + ; Prevents over-polishing docs while structural work waits. + (convergent-budget + ; % for new modules, compilation fixes, wiring, integration + :structural 70 + ; % for bugs found, broken imports, stale references + :corrective 20 + ; % for SPDX headers, doc updates, formatting, style + :perfective 10) + ; ============================================================================ + ; UNIQUE STRENGTH (when mode = divergent) + ; ============================================================================ + ; What makes this project special. Agents should DEEPEN this, not broaden it. + ; Customise this per project — the template default is generic. + (unique-strength + :description "{{PROJECT_UNIQUE_STRENGTH}}" + :deepen-not-broaden #t) + ; ============================================================================ + ; DIVERGENT INVARIANTS + ; ============================================================================ + ; Constraints that divergent mode must NOT violate. + ; These are the riverbanks — diverge within them, not across. + ; "Amplify uniqueness" means deepen, not broaden. + ; + ; Test before any divergent action: + ; "Does this deepen the existing strength, or add a parallel strength?" + ; If parallel → stop. Note as cross-project insight. + (divergent-invariants + ; Customise per project. Examples: + ; "Idris2 only for formal verification — no Lean4, Coq, Agda", + ; "believe_me count must remain zero", + ; "FFI architecture: Idris2 → RefC → Zig → C ABI (no shortcuts)", + :rules ()) + ; Optional: language invariant for the core strength + ; If set, divergent mode will not introduce other languages for this purpose + ; language-invariant = "idris2" + ; ============================================================================ + ; CONSTRAINT HINTS + ; ============================================================================ + ; Help Phase 0 find the critical chain faster. + ; Updated at session end with newly discovered constraints. + (known-constraints + ; Customise per project. Examples: + ; "End-to-end build has never been verified", + ; "libproject.so does not exist yet — all bindings call stubs", + :constraints ()) + ; ============================================================================ + ; STATE FILE VALIDATION + ; ============================================================================ + ; Phase 0 reads the repo deed (status) clause and docs/status/ROADMAP.adoc first, + ; but they may be broken. + ; These rules detect corrupt/template/stale state files. + (state-validation + ; A regex, not a literal list. The list form named the exact strings it was + ; meant to catch ("{{PROJECT}}"), so `just repo-init` substituted the detector along + ; with everything else and shipped reject-if-contains = ["MY_PROJECT", ...] — + ; a guard against unfilled tokens that had been eaten by the filling. The + ; escaped pattern below contains no token for init to match, and covers every + ; upper-snake token rather than the three anyone remembered to enumerate. + ; Enforced by scripts/check-no-placeholders.sh. + :reject-if-matches ("\\{\\{[A-Z][A-Z0-9_]*\\}\\}") + :reject-if-contains ("rsr-template-repo") + :reject-if-project-name-mismatch #t + :staleness-threshold-days 90 + :fallback-files ("TODO.md" "TODO.adoc" "ROADMAP.adoc" "README.adoc")))) + ; coverage — session coverage tracking + ; Updated at the end of each AI agent session. + ; Persists what was visited, what was skipped, and what has MUSTs. + ; + ; Reference: ADR-002 (methodology layer) in hyperpolymath/standards + (coverage + (metadata + :version "1.0.0" + :last-updated "{{CURRENT_DATE}}") + ; ============================================================================ + ; COVERAGE STATE + ; ============================================================================ + ; Updated by agents at session end. Tracks which components have been + ; visited and which have known MUSTs that were skipped. + (coverage + :total-components 0 + :visited-components 0 + :coverage-percent 0)) + ; debt — Meander debt list + ; Things found but not fixed. Carried between sessions. + ; Becomes the next session's Phase 0 input. + ; + ; Reference: ADR-002 (methodology layer) in hyperpolymath/standards + (debt + (metadata + :version "1.0.0" + :last-updated "{{CURRENT_DATE}}")) + ; rra — directives for Robot Repo Automaton (RRA) enforcing the Platonic Ideal root. + (rra + :target "robot-repo-automaton" + (module + :name "root-cleaner" + ; mode can be "enforce" (auto-commit) or "putative" (write to /.auto-rr/ for human review) + (policy + :mode "putative" + :strictness "high") + ; Files that are strictly allowed to exist at the root. CLAUDE.md is the + ; generated arrival pack; git/Claude/pre-commit all read it from the ROOT. + ; The pattern ".*" allows hidden dotfiles implicitly; RRA checks specific + ; dotfile rules below. + (allow-list + :files ("README.adoc" "CHANGELOG.adoc" "Justfile" "LICENSE" "rsr-template-repo_chora.deed" "CITATION.cff" "CLAUDE.md") + :patterns (".*")) + ; Relocation rules: move heavy prose out of the root + (relocations + (move :pattern "*.adoc" :dest "docs/" :except ("README.adoc" "CHANGELOG.adoc")) + (move :pattern "GOVERNANCE.*" :dest "docs/") + (move :pattern "ARCHITECTURE.*" :dest "docs/") + (move :file "MAINTAINERS.adoc" :dest "docs/")) + ; Consolidation rules: reduce the dotfile "wall of noise" + (consolidations + ; Merge legacy environment trackers into mise + (merge :sources (".envrc" ".tool-versions") :engine "mise-translator" :dest ".config/mise.toml") + ; Ensure standard git attributes are consolidated + (merge :sources (".gitattributes-legacy" ".gitattributes-os") :dest ".gitattributes")) + ; Explicitly ignore certain paths from being cleaned + (ignores + :dirs ("src/" "tests/" ".git/" "docs/" ".machine_readable/"))))) + ; Optional ecosystem integrations. + ; Each clause is opt-in; delete the ones your project does not use. + (integrations + ; OPTIONAL: Feedback-o-Tron Integration — Autonomous Bug Reporting + ; Delete this clause if your project does not use feedback-o-tron. + (feedback-o-tron + (integration + :name "feedback-o-tron" + :type "bug-reporter" + :repository "https://github.com/hyperpolymath/feedback-o-tron") + (reporting-config + :platforms ("github" "gitlab" "bugzilla") + :deduplication #t + :audit-logging #t + :auto-file-upstream "on-external-dependency-failure")) + ; OPTIONAL: Proven Integration — Formally Verified Safety Library + ; Delete this clause if your project does not use the proven library. + ; See https://github.com/hyperpolymath/proven for details. + (proven + (integration + :name "proven" + :type "safety-library" + :repository "https://github.com/hyperpolymath/proven" + :version "1.2.0") + (binding-policy + :approach "thin-ffi-wrapper" + :unsafe-patterns "replace-with-proven-equivalent" + :modules-available ( + "SafeMath" + "SafeString" + "SafeJSON" + "SafeURL" + "SafeRegex" + "SafeSQL" + "SafeFile" + "SafeTemplate" + "SafeCrypto" + )) + (adoption-guidance + :priority "high" + :scope "all-string-json-url-crypto-operations" + :migration "incremental — replace unsafe patterns as encountered")) + ; OPTIONAL: VeriSimDB Feed — Cross-Repo Analytics Data Store + ; Delete this clause if your project does not feed data to VeriSimDB. + ; See https://github.com/hyperpolymath/nextgen-databases for details. + (verisimdb + (integration + :name "verisimdb" + :type "data-feed" + :repository "https://github.com/hyperpolymath/nextgen-databases" + :data-store "verisimdb-data") + (feed-config + :emit-scan-results #t + :emit-build-metrics #t + :emit-dependency-graph #t + :format "hexad" + :destination "verisimdb-data/feeds/")) + ; OPTIONAL: Vexometer Integration — Irritation Surface Analysis + ; Delete this clause if your project does not use vexometer. + (vexometer + (integration + :name "vexometer" + :type "friction-measurement" + :repository "https://github.com/hyperpolymath/vexometer") + (measurement-config + :dimensions 10 + :emit-isa-reports #t + :lazy-eliminator #t + :satellite-interventions #t) + (hooks + :cli-tools "measure-on-error" + :ui-panels "measure-on-interaction" + :build-failures "measure-on-failure")) + ; groove — Groove Protocol manifest: the API surfaces this project exposes + ; Consumed by the Groove bridge / + ; zig-unified-api-adapter for snap-on/snap-off service discovery. Edit it to + ; match your project's actual APIs; `just groove-setup` writes :port and the + ; :enabled flags through scripts/deed-clause.sh. + ; See: https://github.com/hyperpolymath/standards/tree/main/groove-protocol + (groove + :version "1.0" + ; Service identity — replace {{REPO}} with your project name + :service "{{REPO}}" + :service-version "0.1.0" + ; Primary port — MUST be unique across the ecosystem. Check PORT-REGISTRY.md + ; in the standards repo before assigning: + ; https://github.com/hyperpolymath/standards/blob/main/PORT-REGISTRY.md + ; 0 = not assigned yet — run `just groove-setup` to assign + :port 0 + ; Health endpoint — used by Groove discovery + :health "/health" + ; Capabilities — what this service can do for others + :capabilities () + ; Dependencies — what this service needs from others + :dependencies () + ; API surfaces this project exposes (dodeca-API). Remove the ones you don't use. + (api-surfaces + (rest :enabled #t :path "/api/v1") + (grpc :enabled #f :port-offset 1) + (graphql :enabled #f :path "/graphql") + (websocket :enabled #f :path "/ws") + (sse :enabled #f :path "/events") + (groove :enabled #t :path "/.well-known/groove")))) + ; Maintenance and development policies, machine-readable canonical. + ; Human companions live in + ; docs/governance/. + (policies + ; Canonical maintenance governance model + (maintenance-axes + (metadata + :version "1.0.0" + :last-updated "{{CURRENT_DATE}}" + :scope "repo") + (discovery + :human-entrypoints ( + "README.adoc" + "docs/governance/MAINTENANCE-CHECKLIST.adoc" + "docs/governance/SOFTWARE-DEVELOPMENT-APPROACH.adoc" + ) + :machine-entrypoints ( + "*_chora.deed (policies (maintenance-axes))" + "*_chora.deed (policies (maintenance-checklist))" + "*_chora.deed (policies (software-development-approach))" + "*_chora.deed" + ".machine_readable/ai/README.adoc" + "*_chora.deed (bot-directives)" + ) + :bots ("hypatia" "gitbot-fleet" "repo visitors")) + (axes + :axis-1 "must > intend > like" + :axis-2 "corrective > adaptive > perfective" + :axis-3 "systems > compliance > effects" + :execution-order "axis-1 > axis-2 > axis-3") + (axis-1-scoping + :required #t + :sources "README, roadmap, status docs, maintenance checklist, CI/security docs" + :markers "TODO/FIXME/XXX/HACK/STUB/PARTIAL" + :idris-unsound-markers "believe_me/assert_total" + :output "scoped work assembly in must/intend/like buckets") + (axis-2-maintenance + :corrective-first #t + :adaptive-second #t + :adaptive-focus "scope changes, stale references, obsolete work culling" + :perfective-third #t + :perfective-source "honest state from axis-1 after corrective/adaptive updates") + (axis-3-audit + :systems-check #t + :compliance-check #t + :effects-check #t + :compliance-focus "seams/compromises/exception register and anti-drift" + :compliance-tooling "panic-attack" + :effects-tooling "ecological checking with sustainabot guidance" + :effects-evidence "benchmark evidence and maintainer dialogue/status review")) + ; Cross-repo maintenance baseline (machine-readable canonical) + (maintenance-checklist + (metadata + :version "1.1.0" + :last-updated "2026-02-24" + :scope "cross-repo" + :source-human "docs/governance/MAINTENANCE-CHECKLIST.adoc" + :companion-human "docs/governance/SOFTWARE-DEVELOPMENT-APPROACH.adoc" + :companion-machine "*_chora.deed (policies (software-development-approach))") + (policy + :single-source #t + :notes "Use this clause as canonical machine policy and keep the AsciiDoc synchronized.") + (maintenance-axes + :scoping-first #t + :execution-order ("scoping" "axis-1" "axis-2" "axis-3") + :axis-1 "must > intend > like" + :axis-2 "corrective > adaptive > perfective" + :axis-3 "systems > compliance > effects") + (scoping + :inputs_required ( + "README" + "roadmap" + "status-docs" + "maintenance-checklist" + "ci-and-security-docs" + ) + :marker_scan_required ("TODO" "FIXME" "XXX" "HACK" "STUB" "PARTIAL") + :idris_unsound_scan_required ("believe_me" "assert_total") + :scope_assembly_buckets ("must" "intend" "like")) + (axis-2-maintenance-rules + :corrective-first #t + :adaptive-second #t + :adaptive_examples ( + "scope-change reconciliation" + "stale-reference removal" + "obsolete-work culling" + ) + :perfective-third #t + :perfective_source "axis-1 honest state after corrective/adaptive updates") + (axis-3-audit-rules + :systems-check #t + :documentation-honesty-check #t + :safety-security-accounted-check #t + :effects-review-check #t + :benchmark-evidence-required #t + :maintainer-dialogue-review-required #t + :compliance-seams-check #t + :exception-register-required #t + :exception-bounded-scope-required #t + :policy-drift-contamination-check #t + :example-drift-risk "single TypeScript exception causing broad ->TypeScript migration" + :compliance-tooling "panic-attack" + :effects-tooling "ecological checking with sustainabot guidance") + (generic-cleanup-finish-off + :root-cleanup-required #t + :stale-work-cull-required #t + :docs-parity-required #t + :machine-human-sync-required #t + :compliance-finish-off-required #t + :effects-finish-off-required #t + :release-prep-summary-required #t + :next-actions-required ("corrective" "adaptive" "perfective")) + (must + :root_control_files ( + ".gitignore" + ".gitattributes" + ".editorconfig" + ".tool-versions" + "Containerfile" + "Justfile" + ) + :root_hosting_files ("CNAME" ".nojekyll") + :ownership_files ("MAINTAINER" ".github/CODEOWNERS") + :machine_readable_required ( + "*_chora.deed (anchor)" + ".machine_readable/contractiles/" + ".machine_readable/ai/" + ".machine_readable/bot_directives/" + ) + :contractiles_required ( + ".machine_readable/contractiles/must/" + ".machine_readable/contractiles/trust/" + ".machine_readable/contractiles/intend/" + ) + :security_required ("www/.well-known/security.txt" "ci-security-scan") + :quality_gate_required ( + "format" + "lint" + "unit-tests" + "integration-tests" + "p2p-tests" + "e2e-tests" + "bench-smoke" + "docs-check" + "security-scan" + ) + :abi_ffi_policy ("ABI Idris2 in src/interface/abi/*.idr" "FFI Zig in ffi/**/*.zig")) + (should + :docs_primary_format "adoc" + :docs_structure ( + "docs/theory" + "docs/practice" + "docs/whitepapers/academic" + "docs/whitepapers/industry" + "docs/proofs" + "docs/reports" + ) + :root_minimization #t + :well_known_metadata #t + :roadmap_honesty_with_dates #t + :ci_doc_format_policy #t) + (could + :generate_human_from_machine #t + :mode_aware_bots ("corrective" "adaptive" "perfective" "audit") + :topology_dashboard #t + :exception_registry #t)) + ; General software development approach (machine-readable) + (software-development-approach + (metadata + :version "1.0.0" + :last-updated "2026-02-24" + :scope "cross-repo" + :source-human "docs/governance/SOFTWARE-DEVELOPMENT-APPROACH.adoc") + (execution + :order ("axis-1" "axis-2" "axis-3")) + (axis-1 + :name "scope" + :priority "must > intend > like" + :inputs ("README" "roadmap" "status-docs" "ci-and-security-docs") + :marker-scan ("TODO" "FIXME" "XXX" "HACK" "STUB" "PARTIAL") + :idris-unsound-scan ("believe_me" "assert_total") + :output "scoped-work-assembly") + (axis-2 + :name "maintenance" + :priority "corrective > adaptive > perfective" + :corrective "defect/regression/safety/security fixes" + :adaptive "scope reconciliation, stale-reference removal, obsolete-work culling" + :perfective "quality improvements derived from axis-1 honest state") + (axis-3 + :name "audit" + :priority "systems > compliance > effects" + :systems "required systems present and operating" + :compliance "exceptions explicit, bounded, and drift-resistant" + :effects "benchmark/operational impact evidence captured and reviewed" + :compliance-tooling "panic-attack" + :effects-tooling "ecological checking with sustainabot guidance") + (cleanup-finish-off + :root-cleanup #t + :stale-work-cull #t + :docs-sync-human-machine #t + :compliance-audit #t + :effects-audit #t + :release-summary ("must" "should" "could") + :next-actions ("corrective" "adaptive" "perfective")) + (collaboration + :maintainer-dialogue-required #t + :dialogue-topics ("what changed" "why" "remaining risks"))) + ; Component Readiness Grades (CRG v1.0) — the machine-readable grade + ; standard; the human companion is docs/governance/CRG-CRITERIA.adoc. + (crg-criteria + (standard + :name "Component Readiness Grades" + :abbreviation "CRG" + :version "1.0" + :date "2026-02-28" + :author "Jonathan D.A. Jewell " + :license "MPL-2.0" + :family "RSR") + (grades + (grade :code "X" :name "Untested" :release-stage #f :ordinal 0 + :description "No testing has been performed. Status unknown." + :evidence-required "none" + :minimum-for #f) + (grade :code "F" :name "Harmful / Wasteful" :release-stage #f :ordinal 1 + :description "Actively harmful, wasteful, or better handled externally. Reject, deprecate, or delegate." + :evidence-required "documented test results showing harm, waste, or redundancy; comparison with alternatives" + :minimum-for #f) + (grade :code "E" :name "Minimal / Salvageable" :release-stage "pre-alpha" :ordinal 2 + :description "Does something slight. Barely functional. Needs redesign or major work." + :evidence-required "at least one successful test case; documented failures and limitations" + :minimum-for #f) + (grade :code "D" :name "Partial / Inconsistent" :release-stage "alpha" :ordinal 3 + :description "Works on some things but not systematically." + :evidence-required "matrix of tested scenarios; documented scope vs actual capabilities" + :minimum-for "alpha") + (grade :code "C" :name "Self-Validated" :release-stage "beta" :ordinal 4 + :description "Tested on the tool/project itself (dogfooding). Reliable in home context." + :evidence-required "active dogfooding; CI integration or equivalent; no known failures in home context" + :minimum-for "beta") + (grade :code "B" :name "Broadly Validated" :release-stage "release-candidate" :ordinal 5 + :description "Tested on at least 6 disparate, unrelated targets." + :evidence-required "list of 6+ diverse targets with test results; evidence of feedback incorporation" + :minimum-for "release-candidate") + (grade :code "A" :name "Field-Proven" :release-stage "stable" :ordinal 6 + :description "Real-world external feedback confirms value. Does no harm in the wild." + :evidence-required "real-world usage data; feedback incorporation evidence; no unresolved harm reports" + :minimum-for "stable")) + (transitions + (promotion :from "X" :to "E" :requirement "Run at least one test. Document results.") + (promotion :from "X" :to "F" :requirement "Evaluate and determine harmful or wasteful.") + (promotion :from "E" :to "D" :requirement "Fix critical failures. Document scope.") + (promotion :from "D" :to "C" :requirement "Dogfood on own project. Fix what breaks.") + (promotion :from "C" :to "B" :requirement "Test on 6+ diverse external targets. Fix what breaks.") + (promotion :from "B" :to "A" :requirement "Ship. Collect external feedback. Demonstrate no harm.") + (demotion :from "A" :to "B" :trigger "External feedback dries up or reveals no longer useful.") + (demotion :from "A" :to "F" :trigger "External feedback reveals component causes harm.") + (demotion :from "B" :to "C" :trigger "Broad validation reveals unfixed failures.") + (demotion :from "C" :to "D" :trigger "Home context changes and component no longer reliable.") + (demotion :from "C" :to "F" :trigger "Dogfooding reveals net negative.") + (demotion :from "D" :to "E" :trigger "Scope narrows to barely functional.") + (demotion :from "any" :to "F" :trigger "Better external alternative makes this pure opportunity cost.")) + (conformance + :rules ( + "Each assessable component MUST have a grade from {X, F, E, D, C, B, A}." + "Each grade above X MUST be supported by evidence per section 4." + "Assessments MUST be recorded in a version-controlled location." + "Assessments MUST be reviewed at least once per release cycle." + "Release stages MUST respect minimum grade thresholds."))) + ; Triaxial Software Development Methodology — the machine-readable axes; + ; the human companion is docs/governance/TSDM.adoc. + (tsdm + :id "tsdm-standard" + :version "1.0.0" + (axes + (axis :ordinal 1 :name "Planning" :levels ("must" "should" "could")) + (axis :ordinal 2 :name "Maintenance" :levels ("corrective" "adaptive" "perfective")) + (axis :ordinal 3 :name "Audit" :levels ("systems" "compliance" "effects"))) + :invariants ( + "Every task MUST map to at least one TSDM coordinate" + "Axis 1 priority governs resource allocation" + "Axis 2 type governs commit categorisation" + "Axis 3 focus governs audit depth"))) + ; rsr-profile — this repo's declared RSR v2.0 capabilities. + ; It carries no canon pin: the (canon) clause above is the pin, exactly once. + ; + ; It declares which capabilities this repo has, + ; per 0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc; the RSR v2.0 conformance oracle + ; (hypatia Hypatia.Rules.RsrConformance) uses `capabilities` to compute the + ; APPLICABLE criteria set — a gated criterion is scored only when its gating + ; capability appears below; everything else is `na` and excluded from the + ; denominator. + ; + ; rsr-template-repo is deliberately MAXIMAL: it ships scaffolding for FFI/ABI + ; seams, in-tree formal proofs, a container, a reproducible build, benchmarks, + ; a docs site, and the full governance trio — so a repo scaffolded FROM it can + ; adopt any of them. Its declared set is therefore near-total, which is exactly + ; what makes it the dogfood target (the gate requires Gold on the applicable + ; set). Every capability below is grounded in a file/dir that actually exists + ; in this tree; capabilities it does NOT have (rust, affinescript, mobile, + ; web-ui, api-service, published-package) are omitted so the oracle does not + ; score this repo against modules it does not carry. + (rsr-profile + :version "1.0.0" + :spec "rsr-criteria-v2" + :declares-against "2.1.1-draft" + ; Role in the estate pipeline: standards (canon) -> THIS (spine) -> scaffoldia + ; (composer) -> minted repos. A spine may carry capability-gated modules it + ; does not declare, for the repos minted from it; see [carrier] in the canon + ; gates table. A minted repo doing the same is still drift. + :role "spine" + ; Effective capability set (direct declaration; no preset shorthand used). + :capabilities ( + ; src/interface/ffi/build.zig + Zig FFI layer + "zig" + ; src/interface/abi.ipkg + src/interface/Abi/ (dependent-typed ABI) + "idris2" + ; scripts/*.sh automation + "bash" + ; src/ command-line entry scaffolding + "cli" + ; consumed/published as a library surface + "library" + ; C-ABI FFI seam (Zig), src/interface/ffi/ + "ffi" + ; formally specified ABI: src/interface/abi.ipkg + src/interface/Abi/ proofs + "abi" + ; verification/proofs/ (in-tree mechanised proofs) + "formal-proofs" + ; .github/workflows/pages.yml (casket/Pages) + "docs-site" + ; build/container/ + .devcontainer/Containerfile + "container" + ; build/guix.scm + "reproducible-build" + ; benches/ + "benchmarks" + ; full AUDIT/AFFIRMATION/GOVERNANCE/MAINTAINERS trio + "governance-tier" + ) + (notes + ; Capabilities intentionally NOT declared (no corresponding code in tree): + ; rust — no *.rs / Cargo.toml (the FFI/ABI layer is Zig + Idris2) + ; affinescript — no *.affine + ; mobile — no Tauri/Dioxus app + ; web-ui — no browser UI + ; api-service — not a network daemon + ; published-package — a scaffold template, not itself a registry artifact + :omitted-rationale "Declaring a capability this repo lacks would make the oracle score it against a module it does not carry — the over-scaffolding failure v2.0 fixes. See 0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc.")) + ; Egress attestation (standards ADR-009; grammar + ; 1-formats/deed/spec/abnf/clauses/egress.abnf). Every job that reads a real + ; secret (anything but GITHUB_TOKEN) is listed with exactly the secrets it + ; reads, the endpoints outside GitHub it reaches, and what holds it to them. + ; Hypatia RE001 accepts an entry here in place of step-security/harden-runner + ; and reports drift between an entry and its workflow. A minted repo inherits + ; these entries with the workflows; when you add secrets.* to a job, or + ; delete a workflow listed here, change this clause in the same commit. + (egress + :policy "standards ADR-009" + (job + :workflow ".github/workflows/push-email-notify.yml" + :job "notify" + :secrets ("SMTP_HOST" "SMTP_PORT" "SMTP_USER" "SMTP_PASS") + :endpoints ("secret:SMTP_HOST") + :enforcement none + :reason "Sends the push notification by SMTP submission to the org mail host, whose name is itself a secret.") + (job + :workflow ".github/workflows/sonarqube.yml" + :job "sonarqube" + :secrets ("SONAR_TOKEN") + :endpoints ("sonarcloud.io" "*.sonarcloud.io" "binaries.sonarsource.com" "keyserver.ubuntu.com" "keys.openpgp.org") + :enforcement none + :reason "Uploads the CI-based analysis to SonarQube Cloud. Skipped when SONAR_TOKEN is unset. SonarSource/sonarqube-scan-action v8.3.0 downloads the scanner from binaries.sonarsource.com and, with signature verification on (its default), fetches the signing key from keyserver.ubuntu.com, falling back to keys.openpgp.org.") + (job + :workflow ".github/workflows/build-notification.yml" + :job "trigger-boj" + :secrets ("BOJ_SERVER_URL") + :endpoints ("secret:BOJ_SERVER_URL" "var:BOJ_SERVER_URL") + :enforcement none + :reason "Notifies the BoJ server of a push. The URL is the secret when set, else the variable that also gates the job.")) +) diff --git a/test/fixtures/deed_egress/valid/empty-clause_chora.deed b/test/fixtures/deed_egress/valid/empty-clause_chora.deed new file mode 100644 index 00000000..bdc10321 --- /dev/null +++ b/test/fixtures/deed_egress/valid/empty-clause_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress)) diff --git a/test/fixtures/deed_egress/valid/github-only_chora.deed b/test/fixtures/deed_egress/valid/github-only_chora.deed new file mode 100644 index 00000000..7e794c40 --- /dev/null +++ b/test/fixtures/deed_egress/valid/github-only_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/release.yaml" :job "_publish-1" :secrets ("RELEASE_PAT") :endpoints () :enforcement none))) diff --git a/test/fixtures/deed_egress/valid/hyphenated-host_chora.deed b/test/fixtures/deed_egress/valid/hyphenated-host_chora.deed new file mode 100644 index 00000000..d35ccfea --- /dev/null +++ b/test/fixtures/deed_egress/valid/hyphenated-host_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress (job :workflow ".github/workflows/my.ci-flow.yaml" :job "a" :secrets ("MY_KEY_2") :endpoints ("api-v2.my-host.example:8443" "x.y") :enforcement none))) diff --git a/test/fixtures/deed_egress/valid/no-egress_chora.deed b/test/fixtures/deed_egress/valid/no-egress_chora.deed new file mode 100644 index 00000000..79aab352 --- /dev/null +++ b/test/fixtures/deed_egress/valid/no-egress_chora.deed @@ -0,0 +1,3 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" +) diff --git a/test/fixtures/deed_egress/valid/template-pair_chora.deed b/test/fixtures/deed_egress/valid/template-pair_chora.deed new file mode 100644 index 00000000..722adb67 --- /dev/null +++ b/test/fixtures/deed_egress/valid/template-pair_chora.deed @@ -0,0 +1,18 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + (egress + :policy "standards ADR-009" + ; A secret-held endpoint and a public one, both without a run-time enforcer. + (job + :workflow ".github/workflows/push-email-notify.yml" + :job "notify" + :secrets ("SMTP_HOST" "SMTP_PORT" "SMTP_USER" "SMTP_PASS") + :endpoints ("secret:SMTP_HOST") + :enforcement none + :reason "Sends the push notification over SMTP submission.") + (job + :workflow ".github/workflows/sonarqube.yml" + :job "sonarqube" + :secrets ("SONAR_TOKEN") + :endpoints ("sonarcloud.io" "*.sonarcloud.io:443" "var:SONAR_HOST") + :enforcement harden-runner-block))) diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index d69e7c5d..1bc8da76 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -10,6 +10,8 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do defp create_repo_with_workflow(yaml_content, filename \\ "test.yml") do repo = Path.join(@tmp_dir, "re_test_#{System.unique_integer([:positive])}") + # unique_integer restarts with each VM, so clear what an earlier run left. + File.rm_rf!(repo) wf = Path.join([repo, ".github", "workflows"]) File.mkdir_p!(wf) File.write!(Path.join(wf, filename), yaml_content) @@ -217,7 +219,7 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do File.rm_rf!(repo) end - test "a credential next to only pinned actions and the repo's own scripts is not reported" do + test "a credential next to only pinned actions and the repo's own scripts is :warn, with no exposure" do repo = create_repo_with_workflow(""" on: [push] @@ -232,7 +234,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do GH_TOKEN: ${{ secrets.DISPATCH_PAT }} """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -287,7 +291,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do - run: ./sync.sh ${{ secrets.BOT_PAT }} """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -330,7 +336,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do PAT: ${{ secrets.REPORT_PAT }} """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -378,7 +386,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do - run: ./sync.sh ${{ secrets.BOT_PAT }} """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -415,7 +425,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do ./publish.sh """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -477,9 +489,14 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do - run: ./other.sh ${{ secrets.OTHER_PAT }} """) - # `deploy` is gated to push; `reply` admits issue_comment; a condition - # that is not a plain event_name test is not interpreted. - assert [%{line: 12}, %{line: 17}] = + # `deploy` is gated to push, so it has no exposure but is still an + # undeclared secret-bearing job (ADR-009); `reply` admits issue_comment; + # a condition that is not a plain event_name test is not interpreted. + assert [ + %{line: 7, severity: :warn, detail: %{exposures: []}}, + %{line: 12, detail: %{exposures: ["an untrusted-input trigger (issue_comment)"]}}, + %{line: 17, detail: %{exposures: ["an untrusted-input trigger (issue_comment)"]}} + ] = repo |> ResearchExtensions.re001_missing_harden_runner() |> Enum.sort_by(& &1.line) File.rm_rf!(repo) @@ -579,7 +596,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do token: ${{ secrets.REPORT_PAT }} """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -613,7 +632,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do - run: ./upload.sh ${{ secrets.REPORT_PAT }} """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -687,7 +708,9 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do run: ./deploy.sh """) - assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + assert [%{severity: :warn, detail: %{exposures: []}}] = + ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) end @@ -764,6 +787,671 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do File.rm_rf!(repo) end + + test "does not count the job token, under either spelling" do + repo = + create_repo_with_workflow(""" + jobs: + labels: + runs-on: ubuntu-latest + steps: + - env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ALSO: ${{ github.token }} + run: gh label list + """) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "points past the job token to the first real secret" do + repo = + create_repo_with_workflow(""" + jobs: + mixed: + runs-on: ubuntu-latest + steps: + - run: gh api --token=${{ secrets.GITHUB_TOKEN }} + - run: deploy --token=${{ secrets.DEPLOY_KEY }} + """) + + assert [%{line: 6, reason: reason}] = ResearchExtensions.re001_missing_harden_runner(repo) + assert reason =~ "job `mixed`" + File.rm_rf!(repo) + end + end + + # ─── RE001 with an egress declaration (standards ADR-009) ────────── + + describe "re001_missing_harden_runner/1 with a repo deed" do + @smtp_workflow """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - uses: hyperpolymath/smtp-notify-action@v0.5.0 + with: + server_address: ${{ secrets.SMTP_HOST }} + password: ${{ secrets.SMTP_PASS }} + """ + + defp write_deed(repo, egress) do + File.write!(Path.join(repo, "x_chora.deed"), """ + ;; SPDX-License-Identifier: CC-BY-SA-4.0 + (repo-deed :schema-version "1.0.0" :canonical-name "x" + :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora" + #{egress}) + """) + end + + defp smtp_job(secrets) do + ~s|(job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets (#{secrets}) :endpoints ("secret:SMTP_HOST") :enforcement none)| + end + + test "an exact declaration satisfies the rule" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + + write_deed( + repo, + ~s|(egress :policy "standards ADR-009" #{smtp_job(~s|"SMTP_PASS" "SMTP_HOST"|)})| + ) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "without a declaration the fix names the deed and the exact entry" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + write_deed(repo, "") + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.line == 7 + assert finding.detail.fix =~ "x_chora.deed" + assert finding.detail.fix =~ ~s|:job "notify" :secrets ("SMTP_HOST" "SMTP_PASS")| + File.rm_rf!(repo) + end + + test "a secret the declaration misses is drift" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + write_deed(repo, "(egress #{smtp_job(~s|"SMTP_HOST"|)})") + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.file == ".github/workflows/notify.yml" + assert finding.reason =~ ~s|reads undeclared "SMTP_PASS"| + File.rm_rf!(repo) + end + + test "a declared secret the job no longer reads is drift" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + write_deed(repo, "(egress #{smtp_job(~s|"SMTP_HOST" "SMTP_PASS" "SMTP_USER"|)})") + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ ~s|declares unread "SMTP_USER"| + refute finding.reason =~ "reads undeclared" + File.rm_rf!(repo) + end + + test "a declaration for a job that reads no real secret is stale" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: gh api --token=${{ secrets.GITHUB_TOKEN }} + """, + "notify.yml" + ) + + write_deed(repo, "(egress #{smtp_job(~s|"SMTP_HOST"|)})") + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.file == "x_chora.deed" + assert finding.reason =~ "stale" + File.rm_rf!(repo) + end + + test "a declaration for a reusable-workflow job is stale" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + uses: org/repo/.github/workflows/mail.yml@v1 + secrets: + host: ${{ secrets.SMTP_HOST }} + """, + "notify.yml" + ) + + write_deed(repo, "(egress #{smtp_job(~s|"SMTP_HOST"|)})") + + assert [%{file: "x_chora.deed"}] = ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) + end + + test "an unreadable clause declares nothing, and the finding says why" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + write_deed(repo, ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify"))|) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ "the repo deed declares nothing ((job …) is missing :secrets)" + File.rm_rf!(repo) + end + + test "harden-runner still satisfies the rule, declared or not" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - uses: step-security/harden-runner@v2 + - run: send --pass=${{ secrets.SMTP_PASS }} + """, + "notify.yml" + ) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + + # A declaration is still an attestation: listing the wrong secrets is + # drift, at :warn because harden-runner contains the job. + write_deed(repo, "(egress #{smtp_job(~s|"SMTP_HOST"|)})") + + assert [%{severity: :warn, reason: reason}] = + ResearchExtensions.re001_missing_harden_runner(repo) + + assert reason =~ ~s|reads undeclared "SMTP_PASS"| + File.rm_rf!(repo) + end + + test "a declared harden-runner enforcement must still be installed" do + workflow = fn hardener -> + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + #{hardener} - run: send --pass=${{ secrets.SMTP_PASS }} + """ + end + + entry = + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("SMTP_PASS") :endpoints () :enforcement harden-runner-block))| + + hardened = + create_repo_with_workflow( + workflow.( + " - uses: step-security/harden-runner@v2\n" <> + " with:\n egress-policy: block\n" + ), + "notify.yml" + ) + + write_deed(hardened, entry) + assert ResearchExtensions.re001_missing_harden_runner(hardened) == [] + + # The hardener was removed, but the deed still claims block enforcement. + bare = create_repo_with_workflow(workflow.(""), "notify.yml") + write_deed(bare, entry) + assert [finding] = ResearchExtensions.re001_missing_harden_runner(bare) + assert finding.reason =~ ":enforcement harden-runner-block" + assert finding.reason =~ "does not install" + + File.rm_rf!(hardened) + File.rm_rf!(bare) + end + + test "a declared block enforcement needs egress-policy: block, not audit or the default" do + check = fn with_lines -> + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - uses: step-security/harden-runner@v2 + #{with_lines} - run: send --pass=${{ secrets.SMTP_PASS }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("SMTP_PASS") :endpoints () :enforcement harden-runner-block))| + ) + + findings = ResearchExtensions.re001_missing_harden_runner(repo) + File.rm_rf!(repo) + findings + end + + assert check.(" with:\n egress-policy: block\n") == [] + assert check.(" with: {egress-policy: block}\n") == [] + + assert [%{reason: audit}] = check.(" with:\n egress-policy: audit\n") + assert audit =~ "runs `egress-policy: audit`" + + # harden-runner audits when no egress-policy is set. + assert [%{reason: default}] = check.("") + assert default =~ "runs `egress-policy: audit`" + end + + test "a quoted job key matches its declaration" do + repo = + create_repo_with_workflow( + """ + jobs: + "notify": + runs-on: ubuntu-24.04 + steps: + - run: send --pass=${{ secrets.SMTP_PASS }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("SMTP_PASS") :endpoints () :enforcement none))| + ) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "references match the uppercase declaration, and github_token is the job token" do + # GitHub matches secret names without regard to case and stores them + # uppercase, so secrets.smtp_pass is SMTP_PASS and secrets.github_token + # is the job token. + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: send --pass=${{ secrets.smtp_pass }} --gh=${{ secrets.github_token }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("SMTP_PASS") :endpoints () :enforcement none))| + ) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "a lowercase job-token reference alone does not count" do + repo = + create_repo_with_workflow(""" + jobs: + labels: + runs-on: ubuntu-latest + steps: + - run: gh label list --token=${{ secrets.github_token }} + """) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "an egress clause nested in another clause declares nothing and says so" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + write_deed(repo, "(meta (egress #{smtp_job(~s|"SMTP_HOST" "SMTP_PASS"|)}))") + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ "(egress …) nested inside another clause" + File.rm_rf!(repo) + end + + test "the index form and a reference later in an expression are secrets too" do + # A declaration listing only VISIBLE must not cover a job that also reads + # secrets['HIDDEN'] or a secret after another operand. + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: send ${{ secrets.VISIBLE }} ${{ secrets['HIDDEN'] }} + - run: other ${{ vars.URL || secrets["LATER"] }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("VISIBLE") :endpoints () :enforcement none))| + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ ~s|reads undeclared "HIDDEN" "LATER"| + File.rm_rf!(repo) + end + + test "text inside a string literal is not a secret reference" do + # Only secrets.REAL is read; the literals merely contain secret-like text. + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo ${{ 'secrets.NOT_A_REFERENCE' }} + - run: echo ${{ format('{0} secrets[''X''] {1}', "secrets.Y", secrets.REAL) }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("REAL") :endpoints () :enforcement none))| + ) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "}} inside a literal does not end the expression" do + # format() escapes literal braces as {{ }}; HIDDEN is still read. + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo ${{ secrets.VISIBLE }} + - run: echo ${{ format('{{Hello {0}!}}', secrets.HIDDEN) }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("VISIBLE") :endpoints () :enforcement none))| + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ ~s|reads undeclared "HIDDEN"| + File.rm_rf!(repo) + end + + test "an expression folded across lines is read whole" do + # GitHub folds the scalar and evaluates the whole expression. + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo ${{ secrets.VISIBLE }} + - run: >- + echo ${{ + secrets.HIDDEN + }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("VISIBLE") :endpoints () :enforcement none))| + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ ~s|reads undeclared "HIDDEN"| + File.rm_rf!(repo) + end + + test "a finding points at the line of the first secret it reads" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo start + - run: >- + echo ${{ + secrets.FIRST }} ${{ secrets.SECOND }} + """, + "notify.yml" + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.line == 8 + File.rm_rf!(repo) + end + + test "drift keeps the job's :high when the credential shares a step with fetched code" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: npm ci && deploy ${{ secrets.VISIBLE }} ${{ secrets.HIDDEN }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("VISIBLE") :endpoints () :enforcement none))| + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.severity == :high + assert finding.reason =~ ~s|reads undeclared "HIDDEN"| + assert finding.reason =~ "in the same step" + assert finding.detail.exposures != [] + File.rm_rf!(repo) + end + + test "a secret in the workflow's top-level env is read by every job" do + repo = + create_repo_with_workflow( + """ + on: push + env: + HIDDEN: ${{ secrets.HIDDEN }} + + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: deploy ${{ secrets.VISIBLE }} + other: + runs-on: ubuntu-24.04 + steps: + - run: echo hi + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("VISIBLE") :endpoints () :enforcement none))| + ) + + assert [drift, other] = + repo + |> ResearchExtensions.re001_missing_harden_runner() + |> Enum.sort_by(& &1.reason) + + assert drift.reason =~ ~s|job `notify`| + assert drift.reason =~ ~s|reads undeclared "HIDDEN"| + # A job with no secret of its own still inherits HIDDEN. + assert other.reason =~ ~s|job `other`| + assert other.line == 3 + File.rm_rf!(repo) + end + + test "harden-runner's owner/repo matches without regard to case" do + repo = + create_repo_with_workflow(""" + jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - uses: Step-Security/Harden-Runner@0000000000000000000000000000000000000000 + - run: deploy ${{ secrets.KEY }} + """) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "the secrets context matches without regard to case" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo ${{ secrets.VISIBLE }} ${{ SECRETS.HIDDEN }} ${{ Secrets['LATER'] }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("VISIBLE") :endpoints () :enforcement none))| + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ ~s|reads undeclared "HIDDEN" "LATER"| + File.rm_rf!(repo) + end + + test "a key computed at run time cannot be declared" do + for expr <- [ + "secrets[env.SECRET_NAME]", + "toJSON(secrets)", + "secrets.*", + "fromJSON(SECRETS)[inputs.k]" + ] do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo ${{ secrets.VISIBLE }} + - run: echo ${{ #{expr} }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("VISIBLE") :endpoints () :enforcement none))| + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo), expr + assert finding.reason =~ "computed at run time", expr + assert finding.reason =~ "does not cover it", expr + assert finding.line == 6, expr + File.rm_rf!(repo) + end + end + + test "a job reading secrets only by a computed key is still a finding" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo ${{ secrets[matrix.key] }} + """, + "notify.yml" + ) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ "computed at run time" + refute finding.reason =~ "does not cover it" + File.rm_rf!(repo) + end + + test "a property named secrets is not the secrets context" do + repo = + create_repo_with_workflow( + """ + jobs: + notify: + runs-on: ubuntu-24.04 + steps: + - run: echo ${{ github.event.client_payload.secrets.NOT_A_SECRET }} + - run: echo ${{ steps.my-secrets.outputs.x }} ${{ secrets.REAL }} + """, + "notify.yml" + ) + + write_deed( + repo, + ~s|(egress (job :workflow ".github/workflows/notify.yml" :job "notify" | <> + ~s|:secrets ("REAL") :endpoints () :enforcement none))| + ) + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end + + test "a malformed deed declares nothing" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + + # No :schema-version and no separators: not a deed, whatever it says. + File.write!(Path.join(repo, "x_chora.deed"), """ + ;; SPDX-License-Identifier: CC-BY-SA-4.0 + (repo-deed(egress(job :workflow ".github/workflows/notify.yml" :job "notify" :secrets("SMTP_HOST" "SMTP_PASS") :endpoints() :enforcement none))) + """) + + assert [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + assert finding.reason =~ "the repo deed declares nothing" + File.rm_rf!(repo) + end + + test "the suggested (job …) entry is a valid deed clause" do + repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") + write_deed(repo, "") + + [finding] = ResearchExtensions.re001_missing_harden_runner(repo) + [entry] = Regex.run(~r/\(job .*?:enforcement none\)/, finding.detail.fix) + write_deed(repo, "(egress #{entry})") + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + end end # ─── RE002 ──────────────────────────────────────────────────────────