From 0f36a4f19708113d0fbac91f88820a2caee843e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:15:29 +0000 Subject: [PATCH 01/18] feat(research_extensions): RE001 accepts a deed egress declaration (standards ADR-009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estate decided against an estate-wide step-security/harden-runner (metadatastician/standards ADR-009). A secret-bearing job may instead be attested in the (egress …) clause of the repo deed. - RE001 ignores secrets.GITHUB_TOKEN (the same token as github.token). - A job listed in the deed with exactly the secrets it reads satisfies RE001; harden-runner still does too. - Drift (declared and read secrets differ) and stale declarations are findings. - Findings name the job and give the exact (job …) entry to add. - Hypatia.Deed: a reader for deed.abnf v1.2.0 and the egress clause profile, tested against the standards fixtures (test/fixtures/deed_egress, copied). - Test helpers clear their temp dir first: System.unique_integer restarts with each VM, so a second run reused a dir an earlier run left files in. Tests: research_extensions_test, deed_test and kyaml_workflow_test, 91 tests, 0 failures, run standalone under Elixir 1.14 (hex.pm is unreachable from the session, so the full mix suite was not run locally). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 317 ++++++++++++++++++ lib/rules/research_extensions.ex | 239 ++++++++++--- test/deed_test.exs | 121 +++++++ test/fixtures/deed_egress/README.adoc | 9 + .../deed_egress/invalid/bad-host_chora.deed | 3 + .../deed_egress/invalid/bad-port_chora.deed | 3 + .../invalid/duplicate-job_chora.deed | 3 + .../invalid/duplicate-secret_chora.deed | 3 + .../invalid/empty-secrets_chora.deed | 3 + .../invalid/enforcement-string_chora.deed | 3 + .../invalid/enforcement-unknown_chora.deed | 3 + .../invalid/github-token_chora.deed | 3 + .../deed_egress/invalid/job-id_chora.deed | 3 + .../invalid/missing-enforcement_chora.deed | 3 + .../invalid/repeated-field_chora.deed | 3 + .../secret-endpoint-unlisted_chora.deed | 3 + .../invalid/secrets-symbols_chora.deed | 3 + .../invalid/stray-member_chora.deed | 3 + .../invalid/two-clauses_chora.deed | 3 + .../invalid/unknown-field_chora.deed | 3 + .../invalid/workflow-path_chora.deed | 3 + .../deed_egress/rsr-template-repo_chora.deed | 119 +++++++ .../deed_egress/valid/empty-clause_chora.deed | 3 + .../deed_egress/valid/github-only_chora.deed | 3 + .../deed_egress/valid/no-egress_chora.deed | 3 + .../valid/template-pair_chora.deed | 18 + test/research_extensions_test.exs | 199 +++++++++++ 27 files changed, 1030 insertions(+), 52 deletions(-) create mode 100644 lib/hypatia/deed.ex create mode 100644 test/deed_test.exs create mode 100644 test/fixtures/deed_egress/README.adoc create mode 100644 test/fixtures/deed_egress/invalid/bad-host_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/bad-port_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/duplicate-job_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/duplicate-secret_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/empty-secrets_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/enforcement-string_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/enforcement-unknown_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/github-token_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/job-id_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/missing-enforcement_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/repeated-field_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/secret-endpoint-unlisted_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/secrets-symbols_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/stray-member_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/two-clauses_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/unknown-field_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/workflow-path_chora.deed create mode 100644 test/fixtures/deed_egress/rsr-template-repo_chora.deed create mode 100644 test/fixtures/deed_egress/valid/empty-clause_chora.deed create mode 100644 test/fixtures/deed_egress/valid/github-only_chora.deed create mode 100644 test/fixtures/deed_egress/valid/no-egress_chora.deed create mode 100644 test/fixtures/deed_egress/valid/template-pair_chora.deed diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex new file mode 100644 index 00000000..c7c680ff --- /dev/null +++ b/lib/hypatia/deed.ex @@ -0,0 +1,317 @@ +# 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.0) 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 rejects anything it cannot read unambiguously instead of guessing, and + reports the reason. + """ + + @doc "Parse deed source text into `{:ok, {head, items}}` or `{:error, reason}`." + def parse(text) when is_binary(text) do + with {:ok, rest} <- header(text), + {:ok, {head, items}, rest} <- form(skip(rest)), + "" <- skip(rest) do + {:ok, {head, items}} + else + {:error, _} = err -> err + rest when is_binary(rest) -> {:error, "trailing content after the form"} + 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] -> {:ok, Path.join(repo_path, 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}`, `: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), + {: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) + @workflow ~r/\A\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml\z/ + @job_id ~r/\A[A-Za-z_][A-Za-z0-9_-]*\z/ + @secret ~r/\A[A-Za-z_][A-Za-z0-9_]*\z/ + @host_port ~r/\A(\*\.)?[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 + case for({:clause, "egress", members} <- items, do: members) do + [] -> {: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_members(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)} + {: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?(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 ────────────────────────────────────────────────── + + # header = 1*spdx-line + defp header(text) do + case spdx_lines(text, 0) do + {0, _} -> {:error, "no ';; SPDX-' header line"} + {_, rest} -> {:ok, rest} + end + end + + defp spdx_lines(";; SPDX-" <> _ = text, n) do + case String.split(text, "\n", parts: 2) do + [_line, rest] -> spdx_lines(rest, n + 1) + [_line] -> {n + 1, ""} + end + end + + defp spdx_lines(text, n), do: {n, text} + + # token-sep = 1*(SP / line-end / comment) + defp skip(<>) when c in [?\s, ?\n, ?\r], do: skip(rest) + + defp skip(";" <> rest) do + case String.split(rest, "\n", parts: 2) do + [_, after_comment] -> skip(after_comment) + [_] -> "" + end + end + + defp skip(rest), do: rest + + 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(skip(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 '('"} + + defp items(")" <> rest, acc), do: {:ok, Enum.reverse(acc), rest} + defp items("", _acc), do: {:error, "unbalanced parens"} + + defp items(":" <> rest, acc) do + with {:ok, kw, rest} <- symbol(rest), + {:ok, value, rest} <- value(skip(rest)) do + items(skip(rest), [{:field, kw, value} | acc]) + end + end + + defp items("(" <> rest, acc) do + with {:ok, head, rest} <- symbol(rest), + {:ok, inner, rest} <- items(skip(rest), []) do + items(skip(rest), [{:clause, head, inner} | acc]) + end + end + + defp items(_, _acc), do: {:error, "expected a field or a clause"} + + defp value("\"" <> rest), do: string(rest, []) + defp value("#u5\"" <> rest), do: tagged(:uuid5, string(rest, [])) + defp value("#u8\"" <> rest), do: tagged(: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(skip(rest), []) + + defp value("'" <> rest) do + with {:ok, v, rest} <- value(skip(rest)) do + {:ok, {:quoted, v}, rest} + 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 + + defp list(")" <> rest, acc), do: {:ok, {:list, Enum.reverse(acc)}, rest} + defp list("", _acc), do: {:error, "unbalanced parens in a list"} + + defp list(text, acc) do + with {:ok, v, rest} <- value(text) do + list(skip(rest), [v | acc]) + 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"} + defp string(<>, acc), do: string(rest, [<> | acc]) + 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 69ae82bd..85bf58a3 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -21,11 +21,13 @@ defmodule Hypatia.Rules.ResearchExtensions do Each rule cites its upstream source (tool name and/or paper). - - **RE001** — Workflow accesses `secrets.*` but does not install - `step-security/harden-runner`. StepSecurity's Harden-Runner + - **RE001** — Workflow job reads a real secret (anything but + `GITHUB_TOKEN`) with neither `step-security/harden-runner` nor an + egress declaration in the repo deed. StepSecurity's Harden-Runner provides outbound-egress monitoring/blocking for workflows; its absence on a secrets-touching workflow leaves a clear telemetry - gap. From StepSecurity Harden-Runner deployment guide. + gap. From StepSecurity Harden-Runner deployment guide. The deed's + `(egress …)` clause is the estate's alternative (standards ADR-009). - **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,13 +160,38 @@ defmodule Hypatia.Rules.ResearchExtensions do # ─── RE001: Harden-Runner absent on secrets-touching workflow ───────── @doc """ - RE001: Reports each locally executed workflow job that references - `${{ secrets.* }}` without a `step-security/harden-runner@...` step in the - same job. Full-line comments and reusable-workflow jobs without a direct - `runs-on` field are ignored. + RE001: Reports each locally executed workflow job that references a real + secret, `${{ secrets.X }}` for any X but `GITHUB_TOKEN`, with neither a + `step-security/harden-runner@...` step in the same job nor an egress + declaration for the job in the repo deed. Full-line comments and + reusable-workflow jobs without a direct `runs-on` field are ignored. - Each finding points to the first matching secret reference in the job. - Provenance: StepSecurity Harden-Runner deployment guide. + Each finding points to the first real secret reference in the job. + Provenance: StepSecurity Harden-Runner deployment guide; the declaration is + standards ADR-009. + + ## The job token is not a secret here + + `secrets.GITHUB_TOKEN` is the job token, the same one as `github.token`, which + this rule never counted. What it can reach is bounded by `permissions:`, which + other rules check. Counting it made the finding depend on which spelling a + workflow used. + + ## The egress declaration (standards ADR-009) + + A repo deed (`_chora.deed`) may attest, in its `(egress …)` clause, the + secrets and endpoints of each secret-bearing job + (`1-formats/deed/spec/EGRESS-CLAUSE-SPEC.adoc` in standards). A job listed + there with *exactly* the secrets it references satisfies the rule. An + attestation that has stopped being true is reported, not trusted: + + * 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. ## Why this is structural, not a regex @@ -192,60 +219,168 @@ defmodule Hypatia.Rules.ResearchExtensions do Severity: `:warn`. 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.secrets}), nil} + :none -> {nil, %{}, nil} + {:error, file, reason} -> {file, %{}, "the repo deed declares nothing (#{reason})"} + end - # 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) - - active_lines - |> workflow_job_lines() - |> Enum.flat_map(fn job_lines -> - secret_line = - Enum.find(job_lines, fn {line, _} -> - Regex.match?(~r/\$\{\{\s*secrets\.[A-Za-z_][A-Za-z0-9_]*/, line) - end) + jobs = + repo_path + |> workflow_files() + |> Enum.flat_map(fn path -> re001_jobs(path, Path.relative_to(path, repo_path)) end) - # 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") - installs_harden? = harden_runner_step?(job_lines) + job_findings = + Enum.flat_map(jobs, fn job -> + case Map.fetch(declared, {job.workflow, job.id}) do + _ when job.hardened? -> + [] - if not is_nil(secret_line) and local_runner? and not installs_harden? do - {_source, line} = secret_line + {:ok, secrets} when secrets == job.secrets -> + [] + + {:ok, secrets} -> + [re001_drift(job, deed_file, secrets)] + + :error -> + [re001_undeclared(job, deed_file, deed_note)] + end + end) + # Every declaration must still describe a local job that reads a real secret. + live = MapSet.new(jobs, &{&1.workflow, &1.id}) + + stale_findings = + declared + |> Map.keys() + |> Enum.reject(&MapSet.member?(live, &1)) + |> Enum.sort() + |> Enum.map(&re001_stale(&1, deed_file)) + + job_findings ++ stale_findings + end + + # The locally executed jobs of one workflow that read a real secret. + 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. + path + |> read_workflow() + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.reject(fn {line, _} -> String.starts_with?(String.trim_leading(line), "#") end) + |> 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 {line, n} <- job_lines, + [_, name] <- Regex.scan(~r/\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)/, line), + name != "GITHUB_TOKEN", + do: {name, n} + + case refs do + [] -> + [] + + [{_, first_line} | _] -> [ %{ - rule: "RE001", - file: rel, - severity: :warn, - line: line, - reason: - "job in #{rel} references `secrets.*` but does not install " <> - "`step-security/harden-runner` — review outbound-egress monitoring", - action: :report, - detail: %{ - fix: - "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: first_line, + secrets: MapSet.new(refs, &elem(&1, 0)), + hardened?: harden_runner_step?(job_lines) } ] - else - [] - end - end) + end end) end + defp job_id(header) do + [_, id] = Regex.run(~r/^\s+("[^"]+"|'[^']+'|[A-Za-z0-9_-]+):/, header) + String.trim(id, "\"") |> String.trim("'") + end + + 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) " <> + "— standards ADR-009." + + %{ + rule: "RE001", + file: job.workflow, + severity: :warn, + 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" <> + if(deed_note, do: "; #{deed_note}", else: ""), + action: :report, + detail: %{ + fix: + "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, " <> declare + } + } + end + + defp re001_drift(job, deed_file, declared) do + unlisted = MapSet.difference(job.secrets, declared) + unused = MapSet.difference(declared, job.secrets) + + %{ + rule: "RE001", + file: job.workflow, + severity: :warn, + 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 + ), + "; " + ), + action: :report, + detail: %{ + 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_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 direct_job_field?(job_lines, field) do case job_field_indent(job_lines) do nil -> diff --git a/test/deed_test.exs b/test/deed_test.exs new file mode 100644 index 00000000..ac638934 --- /dev/null +++ b/test/deed_test.exs @@ -0,0 +1,121 @@ +# 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 "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 "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)) + } + + 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 + end +end diff --git a/test/fixtures/deed_egress/README.adoc b/test/fixtures/deed_egress/README.adoc new file mode 100644 index 00000000..c4ec3125 --- /dev/null +++ b/test/fixtures/deed_egress/README.adoc @@ -0,0 +1,9 @@ +// 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_egress.js` does. Re-copy them when that directory changes; +do not edit them here. 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/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_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/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/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/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/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/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/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..20470ce6 --- /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") + :enforcement harden-runner-block))) diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index 05339a7d..a5b42d27 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) @@ -189,6 +191,203 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do assert ResearchExtensions.re001_missing_harden_runner(repo) == [] 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" + ) + + write_deed(repo, "(egress #{smtp_job(~s|"SMTP_HOST"|)})") + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + 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 end # ─── RE002 ────────────────────────────────────────────────────────── From d6b8d828f3c1f359b3e8802bb07bee7c188077f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:15:41 +0000 Subject: [PATCH 02/18] docs(changelog): RE001 egress declaration (standards ADR-009) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- CHANGELOG.adoc | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index efe728d4..b4bc073e 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,26 @@ 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); +* names the job and gives the exact `(job …)` entry in its fix text. + +`Hypatia.Deed` is a new reader for DEED documents and the `(egress …)` clause +profile. `test/deed_test.exs` checks it against the standards fixtures, so it +accepts and rejects exactly what `deed_egress.js` does. + === Fixed ==== `mix compile` was impossible: invalid guard-as-pattern in `ActionsLock` (2026-10-25) From a154977724e46d7e1a3c34bc79de1510b7dea942 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:17:30 +0000 Subject: [PATCH 03/18] Hypatia.Deed: accept var:NAME egress endpoints (standards egress.abnf) Tracks the standards profile change: an endpoint may be held in a configuration variable. Fixtures re-copied (adds invalid/bad-var); 92 tests, 0 failures (research_extensions, deed, kyaml_workflow). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 1 + test/fixtures/deed_egress/invalid/bad-var_chora.deed | 3 +++ test/fixtures/deed_egress/valid/template-pair_chora.deed | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/deed_egress/invalid/bad-var_chora.deed diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index c7c680ff..ff4a6915 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -166,6 +166,7 @@ defmodule Hypatia.Deed do 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 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/valid/template-pair_chora.deed b/test/fixtures/deed_egress/valid/template-pair_chora.deed index 20470ce6..722adb67 100644 --- a/test/fixtures/deed_egress/valid/template-pair_chora.deed +++ b/test/fixtures/deed_egress/valid/template-pair_chora.deed @@ -14,5 +14,5 @@ :workflow ".github/workflows/sonarqube.yml" :job "sonarqube" :secrets ("SONAR_TOKEN") - :endpoints ("sonarcloud.io" "*.sonarcloud.io:443") + :endpoints ("sonarcloud.io" "*.sonarcloud.io:443" "var:SONAR_HOST") :enforcement harden-runner-block))) From 0bcbd55ed089ffc5203e6647a90918e57b80f026 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:23:26 +0000 Subject: [PATCH 04/18] docs: the standards egress checker is deed_lint.js --egress standards#1352 folded deed_egress.js into deed_lint.js to satisfy its no-JS ratchet. Text-only change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- CHANGELOG.adoc | 2 +- test/fixtures/deed_egress/README.adoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index b4bc073e..8be9e24a 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -28,7 +28,7 @@ to declare egress instead of adopting harden-runner everywhere. RE001 now: `Hypatia.Deed` is a new reader for DEED documents and the `(egress …)` clause profile. `test/deed_test.exs` checks it against the standards fixtures, so it -accepts and rejects exactly what `deed_egress.js` does. +accepts and rejects exactly what `deed_lint.js --egress` does. === Fixed diff --git a/test/fixtures/deed_egress/README.adoc b/test/fixtures/deed_egress/README.adoc index c4ec3125..45ba33ac 100644 --- a/test/fixtures/deed_egress/README.adoc +++ b/test/fixtures/deed_egress/README.adoc @@ -5,5 +5,5 @@ 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_egress.js` does. Re-copy them when that directory changes; +checker `deed_lint.js --egress` does. Re-copy them when that directory changes; do not edit them here. From 8ec360dab3109a9c6878e1023c9867ba8c166283 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:33:31 +0000 Subject: [PATCH 05/18] ci: run the RE001 and deed reader tests in the rule regression step The rule regression step runs a fixed list of test files, and the tests for this PR's RE001 change (test/research_extensions_test.exs) and the new Hypatia.Deed reader (test/deed_test.exs) were not on it, so CI compiled the code but never ran them. Locally, standalone under Elixir 1.14: 82 tests, 0 failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 36cfb2172adf3ed5eb264addc73ac4d3d6977763 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:41:00 +0000 Subject: [PATCH 06/18] Hypatia.Deed, RE001: uppercase secret names, top-level egress only, RFC 1123 labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tracks standards#1352's fixes to the egress profile, prompted by Codex review there: - GitHub matches secret names without regard to case and stores them uppercase. RE001 now uppercases each secrets.X reference before comparing, so secrets.github_token is the job token and does not count, and secrets.smtp_pass matches a declared "SMTP_PASS". The reader accepts only the stored, uppercase form in :secrets and var:/secret: endpoints. - An (egress …) clause nested inside another clause is refused instead of being read as no declaration; the RE001 finding names the reason. - Hostname labels start and end with a letter or digit; workflow paths have no empty name segments, as egress.abnf. Fixtures re-copied (9 new). Three new RE001 tests. 94 tests, 0 failures (research_extensions, deed), run standalone under Elixir 1.14; CI runs both files on 1.19.4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 23 +++++++-- lib/rules/research_extensions.ex | 5 +- .../invalid/github-token-lowercase_chora.deed | 3 ++ .../invalid/label-leading-hyphen_chora.deed | 3 ++ .../invalid/label-trailing-hyphen_chora.deed | 3 ++ .../invalid/nested-egress_chora.deed | 3 ++ .../invalid/secret-lowercase_chora.deed | 3 ++ .../invalid/var-lowercase_chora.deed | 3 ++ .../invalid/workflow-dot-start_chora.deed | 3 ++ .../invalid/workflow-double-dot_chora.deed | 3 ++ .../valid/hyphenated-host_chora.deed | 3 ++ test/research_extensions_test.exs | 49 +++++++++++++++++++ 12 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/deed_egress/invalid/github-token-lowercase_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/label-leading-hyphen_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/label-trailing-hyphen_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/nested-egress_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/secret-lowercase_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/var-lowercase_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/workflow-dot-start_chora.deed create mode 100644 test/fixtures/deed_egress/invalid/workflow-double-dot_chora.deed create mode 100644 test/fixtures/deed_egress/valid/hyphenated-host_chora.deed diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index ff4a6915..1cc68b98 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -89,13 +89,25 @@ defmodule Hypatia.Deed do @required ~w(workflow job secrets endpoints enforcement) @allowed MapSet.new(["reason" | @required]) @enforcement ~w(none harden-runner-audit harden-runner-block) - @workflow ~r/\A\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml\z/ + # 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/ - @secret ~r/\A[A-Za-z_][A-Za-z0-9_]*\z/ - @host_port ~r/\A(\*\.)?[A-Za-z0-9][A-Za-z0-9-]*(\.[A-Za-z0-9][A-Za-z0-9-]*)*(?::(?[0-9]{1,5}))?\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"} @@ -103,6 +115,11 @@ defmodule Hypatia.Deed do 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 members |> Enum.reduce_while({:ok, []}, fn diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 85bf58a3..bd5fade8 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -277,7 +277,10 @@ defmodule Hypatia.Rules.ResearchExtensions do |> Enum.flat_map(fn [{header, _} | _] = job_lines -> refs = for {line, n} <- job_lines, - [_, name] <- Regex.scan(~r/\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)/, line), + [_, ref] <- Regex.scan(~r/\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)/, line), + # GitHub matches secret names without regard to case and stores + # them uppercase; the deed declares that stored form. + name = String.upcase(ref), name != "GITHUB_TOKEN", do: {name, n} 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/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/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/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/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/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/research_extensions_test.exs b/test/research_extensions_test.exs index a5b42d27..7761dd17 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -388,6 +388,55 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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 end # ─── RE002 ────────────────────────────────────────────────────────── From d262218ba9e31edbf9efcf1ef30320642f84ad56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:45:42 +0000 Subject: [PATCH 07/18] RE001: index-form and mid-expression secret refs; strict deed reader; valid fix text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on this PR: - P1: ${{ secrets['NAME'] }} (and "NAME") was invisible to RE001, so a job could read an undeclared secret through the index form and still match a declaration. secret_refs/1 now reads every ${{ … }} expression on a line for both the property and index forms, anywhere in the expression (${{ vars.X || secrets.Y }} reads Y; the old pattern needed secrets right after ${{). - P2: Hypatia.Deed.parse/1 accepted forms deed.abnf rejects: no :schema-version, and missing token separators (Codex's example (repo-deed(egress(job …))) parsed). The reader now requires exactly one string :schema-version and a separator before every field and clause, between a keyword and its value, and between list values. A deed that fails declares nothing, and the RE001 finding says why. - P2: the suggested (job …) entry carried ":endpoints (…)", which is not a deed value. It now emits ":endpoints ()" and says what to list there; a test pastes the suggested entry into a deed and expects RE001 to pass. The fourth finding (case) was already fixed in 36cfb21. 109 tests, 0 failures (research_extensions, deed, kyaml_workflow), standalone under Elixir 1.14. RE001 on real trees is unchanged: template 0, choreographic-types 1, hypatia 11, standards 15. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 61 +++++++++++++++++++++++++------ lib/rules/research_extensions.ex | 22 ++++++++--- test/deed_test.exs | 26 +++++++++++++ test/research_extensions_test.exs | 53 +++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index 1cc68b98..ea699515 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -24,7 +24,8 @@ defmodule Hypatia.Deed do def parse(text) when is_binary(text) do with {:ok, rest} <- header(text), {:ok, {head, items}, rest} <- form(skip(rest)), - "" <- skip(rest) do + "" <- skip(rest), + :ok <- schema_version(items) do {:ok, {head, items}} else {:error, _} = err -> err @@ -32,6 +33,20 @@ defmodule Hypatia.Deed do 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). @@ -244,7 +259,7 @@ defmodule Hypatia.Deed do 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(skip(rest), []) do + {:ok, items, rest} <- items(rest, []) do {:ok, {head, items}, rest} else :bad_head -> {:error, "invalid doc-head"} @@ -254,24 +269,41 @@ defmodule Hypatia.Deed do defp form(_), do: {:error, "a deed form must start with '('"} - defp items(")" <> rest, acc), do: {:ok, Enum.reverse(acc), rest} - defp items("", _acc), do: {:error, "unbalanced parens"} + # *(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 - defp items(":" <> rest, acc) do + # field = keyword token-sep value + defp item(":" <> rest) do with {:ok, kw, rest} <- symbol(rest), - {:ok, value, rest} <- value(skip(rest)) do - items(skip(rest), [{:field, kw, value} | acc]) + {:ok, rest} <- separator(rest, ":#{kw}"), + {:ok, value, rest} <- value(rest) do + {:ok, {:field, kw, value}, rest} end end - defp items("(" <> rest, acc) do + defp item("(" <> rest) do with {:ok, head, rest} <- symbol(rest), - {:ok, inner, rest} <- items(skip(rest), []) do - items(skip(rest), [{:clause, head, inner} | acc]) + {:ok, inner, rest} <- items(rest, []) do + {:ok, {:clause, head, inner}, rest} end end - defp items(_, _acc), do: {:error, "expected a field or a clause"} + 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, [])) @@ -303,12 +335,17 @@ defmodule Hypatia.Deed do defp tagged(tag, {:ok, {:string, s}, rest}), do: {:ok, {tag, s}, rest} defp tagged(_tag, err), do: err + # list = "(" [value *(token-sep value)] [token-sep] ")" defp list(")" <> rest, acc), do: {:ok, {:list, Enum.reverse(acc)}, rest} defp list("", _acc), do: {:error, "unbalanced parens in a list"} defp list(text, acc) do with {:ok, v, rest} <- value(text) do - list(skip(rest), [v | acc]) + 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 diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index bd5fade8..c482d289 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -277,10 +277,7 @@ defmodule Hypatia.Rules.ResearchExtensions do |> Enum.flat_map(fn [{header, _} | _] = job_lines -> refs = for {line, n} <- job_lines, - [_, ref] <- Regex.scan(~r/\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)/, line), - # GitHub matches secret names without regard to case and stores - # them uppercase; the deed declares that stored form. - name = String.upcase(ref), + name <- secret_refs(line), name != "GITHUB_TOKEN", do: {name, n} @@ -302,6 +299,19 @@ defmodule Hypatia.Rules.ResearchExtensions do end) end + # Every secret a line reads inside ${{ … }}: 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. + @secret_ref ~r/\bsecrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\])/ + defp secret_refs(line) do + for [_, expr] <- Regex.scan(~r/\$\{\{(.*?)\}\}/, line), + match <- Regex.scan(@secret_ref, expr), + name = match |> Enum.drop(1) |> Enum.find(&(&1 != "")), + do: String.upcase(name) + end + defp job_id(header) do [_, id] = Regex.run(~r/^\s+("[^"]+"|'[^']+'|[A-Za-z0-9_-]+):/, header) String.trim(id, "\"") |> String.trim("'") @@ -312,8 +322,8 @@ defmodule Hypatia.Rules.ResearchExtensions do "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) " <> - "— standards ADR-009." + ":secrets (#{quoted_list(job.secrets)}) :endpoints () :enforcement none), " <> + "listing in :endpoints every host outside GitHub the job reaches — standards ADR-009." %{ rule: "RE001", diff --git a/test/deed_test.exs b/test/deed_test.exs index ac638934..1f2429d1 100644 --- a/test/deed_test.exs +++ b/test/deed_test.exs @@ -58,6 +58,32 @@ defmodule Hypatia.DeedTest do 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 + 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")) diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index 7761dd17..82c6802c 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -437,6 +437,59 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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 "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 ────────────────────────────────────────────────────────── From 7fbd40491c6e94288c8e6a6987f2147e81369b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 01:54:48 +0000 Subject: [PATCH 08/18] RE001: skip string literals in expressions; deed: one :policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the egress declaration path: - secret_refs/1 now consumes each string literal in a ${{ … }} expression whole while scanning, so ${{ 'secrets.X' }} is not read as a reference to X. The index form secrets['X'] is still matched. - egress_declarations/1 rejects an (egress …) clause that repeats :policy, as standards deed_lint.js does. Order stays free, as egress.abnf allows: :policy may follow the job clauses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 6 ++++++ lib/rules/research_extensions.ex | 7 +++++-- test/deed_test.exs | 14 ++++++++++++++ test/research_extensions_test.exs | 25 +++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index ea699515..321d0b7d 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -136,6 +136,12 @@ defmodule Hypatia.Deed do 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} diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index c482d289..95a7d718 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -303,12 +303,15 @@ defmodule Hypatia.Rules.ResearchExtensions do # 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. - @secret_ref ~r/\bsecrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\])/ + # stored form, so names are uppercased here. The scan runs left to right and + # consumes each string literal whole, so text inside one, such as + # ${{ 'secrets.X' }}, is not read as a reference. + @secret_ref ~r/\bsecrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)")\s*\])|'(?:[^']|'')*'|"[^"]*"/ defp secret_refs(line) do for [_, expr] <- Regex.scan(~r/\$\{\{(.*?)\}\}/, line), match <- Regex.scan(@secret_ref, expr), name = match |> Enum.drop(1) |> Enum.find(&(&1 != "")), + name != nil, do: String.upcase(name) end diff --git a/test/deed_test.exs b/test/deed_test.exs index 1f2429d1..50201b88 100644 --- a/test/deed_test.exs +++ b/test/deed_test.exs @@ -111,6 +111,20 @@ defmodule Hypatia.DeedTest do 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 "returns the declared jobs with their secrets" do repo = repo_with_fixture("valid", "template-pair_chora.deed") diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index 82c6802c..eea76021 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -465,6 +465,31 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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 "a malformed deed declares nothing" do repo = create_repo_with_workflow(@smtp_workflow, "notify.yml") From 39dbb0f20d84da71473236be6d79bdf159d6f423 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:03:07 +0000 Subject: [PATCH 09/18] RE001: string-aware expression scan; deed: quote only symbols and lists Three review findings: - An expression now ends at the first }} outside a string literal, so format('{{x}}', secrets.Y), GitHub's escaped-brace form, still reads Y. The old non-greedy match stopped inside the literal and let an undeclared secret pass a declaration. - Only the top-level secrets context counts: a property named secrets (github.event.client_payload.secrets.X, steps.my-secrets.outputs.x) is not a secret reference. - quoted = "'" ( symbol / list ), as deed.abnf has it: a quoted string, integer or boolean is refused, and so is a separator after the quote. apg-js run on deed.abnf agrees on every case in the new test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 8 +++-- lib/rules/research_extensions.ex | 15 ++++++---- test/deed_test.exs | 9 ++++++ test/research_extensions_test.exs | 50 +++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index 321d0b7d..e51d70e7 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -318,9 +318,13 @@ defmodule Hypatia.Deed do defp value("#f" <> rest), do: {:ok, {:boolean, false}, rest} defp value("(" <> rest), do: list(skip(rest), []) + # quoted = "'" ( symbol / list ): no separator after the quote, and nothing + # else may be quoted. defp value("'" <> rest) do - with {:ok, v, rest} <- value(skip(rest)) do - {:ok, {:quoted, v}, rest} + 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 diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 95a7d718..ef0af58b 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -303,12 +303,17 @@ defmodule Hypatia.Rules.ResearchExtensions do # 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 scan runs left to right and - # consumes each string literal whole, so text inside one, such as - # ${{ 'secrets.X' }}, is not read as a reference. - @secret_ref ~r/\bsecrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[\s*(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)")\s*\])|'(?:[^']|'')*'|"[^"]*"/ + # stored form, so names are uppercased here. + # + # 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). + @expression ~r/\$\{\{((?:'(?:[^']|'')*'|[^'}]|\}(?!\}))*)\}\}/ + @secret_ref ~r/(? Enum.drop(1) |> Enum.find(&(&1 != "")), name != nil, diff --git a/test/deed_test.exs b/test/deed_test.exs index 50201b88..a440d2d4 100644 --- a/test/deed_test.exs +++ b/test/deed_test.exs @@ -84,6 +84,15 @@ defmodule Hypatia.DeedTest do assert {:error, _} = Deed.parse(hdr <> ~s|(repo-deed :schema-version "1.0.0" :a #tx)|) 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")) diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index eea76021..80efa7e7 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -490,6 +490,56 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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 "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") From d23d4896a7394e17795521ee5734665173ee3ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:20:19 +0000 Subject: [PATCH 10/18] Deed reader follows deed.abnf; RE001 reads expressions across lines Three review findings, and the root cause behind the run of deed ones: the reader was a second, looser reading of the grammar. It now follows deed.abnf rule by rule, plus the gate's semantic checks: - strings take str-char only: no raw control character or DEL; - token-sep is SP, CRLF, LF or a comment that ends in a line-end; a bare CR, a tab, or an unterminated final comment is refused; - SPDX header lines are text-chars with a non-blank payload; - a #u8 body is domain:name with a printable ASCII domain. One departure, kept on purpose: a separator may come before a list's first value. deed.abnf has none there, but deed_lint accepts it and the template's live deed writes every multi-line list that way; refusing it would stop the worked model declaring anything. The template's deed is now a fixture. Checked against apg-js running deed.abnf (with only that list rule widened) on 16,000 mutated deeds: the reader accepts nothing the grammar rejects. The reader at the previous commit accepted 515 such deeds in the first 6,000. RE001 now scans each job as one text, so an expression a folded YAML scalar splits across lines is read whole, and a finding still points at the line of its first secret. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 103 +- lib/rules/research_extensions.ex | 42 +- test/deed_test.exs | 59 + test/fixtures/deed_egress/README.adoc | 5 + .../rsr-template-repo_chora.deed | 1114 +++++++++++++++++ test/research_extensions_test.exs | 50 + 6 files changed, 1328 insertions(+), 45 deletions(-) create mode 100644 test/fixtures/deed_egress/template-main/rsr-template-repo_chora.deed diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index e51d70e7..dd70e488 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -16,13 +16,18 @@ defmodule Hypatia.Deed do value = {:string, s} | {:symbol, s} | {:integer, n} | {:boolean, b} | {:uuid5, s} | {:uuid8, s} | {:quoted, value} | {:list, [value]} - It rejects anything it cannot read unambiguously instead of guessing, and - reports the reason. + 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. The one departure, a separator before a + list's first value, is noted at `list_open/1`. """ @doc "Parse deed source text into `{:ok, {head, items}}` or `{:error, reason}`." def parse(text) when is_binary(text) do - with {:ok, rest} <- header(text), + 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 @@ -233,35 +238,51 @@ defmodule Hypatia.Deed do # ─── Lexical layer ────────────────────────────────────────────────── - # header = 1*spdx-line - defp header(text) do - case spdx_lines(text, 0) do - {0, _} -> {:error, "no ';; SPDX-' header line"} - {_, rest} -> {:ok, rest} - end - end + # 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") - defp spdx_lines(";; SPDX-" <> _ = text, n) do - case String.split(text, "\n", parts: 2) do - [_line, rest] -> spdx_lines(rest, n + 1) - [_line] -> {n + 1, ""} - end - end + # 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: {n, text} + 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) - # token-sep = 1*(SP / line-end / comment) - defp skip(<>) when c in [?\s, ?\n, ?\r], do: skip(rest) + nil when n == 0 -> + {:error, "no ';; SPDX-' header line"} - defp skip(";" <> rest) do - case String.split(rest, "\n", parts: 2) do - [_, after_comment] -> skip(after_comment) - [_] -> "" + 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, @@ -313,10 +334,10 @@ defmodule Hypatia.Deed do defp value("\"" <> rest), do: string(rest, []) defp value("#u5\"" <> rest), do: tagged(:uuid5, string(rest, [])) - defp value("#u8\"" <> rest), do: tagged(:uuid8, 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(skip(rest), []) + defp value("(" <> rest), do: list_open(rest) # quoted = "'" ( symbol / list ): no separator after the quote, and nothing # else may be quoted. @@ -345,9 +366,26 @@ defmodule Hypatia.Deed do defp tagged(tag, {:ok, {:string, s}, rest}), do: {:ok, {tag, s}, rest} defp tagged(_tag, err), do: err - # list = "(" [value *(token-sep value)] [token-sep] ")" - defp list(")" <> rest, acc), do: {:ok, {:list, Enum.reverse(acc)}, rest} - defp list("", _acc), do: {:error, "unbalanced parens in a list"} + # 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 = "(" [value *(token-sep value)] [token-sep] ")". One departure from + # deed.abnf: a separator may come before the first value, as the gate allows + # and the estate's worked-model deed (rsr-template-repo) writes every + # multi-line list. It cannot change what a value is. + 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 @@ -369,7 +407,14 @@ defmodule Hypatia.Deed do 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"} - defp string(<>, acc), do: string(rest, [<> | acc]) + # 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.*\/<>=!?+_-]*/ diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index ef0af58b..7905f5ad 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -275,11 +275,7 @@ defmodule Hypatia.Rules.ResearchExtensions do # 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 {line, n} <- job_lines, - name <- secret_refs(line), - name != "GITHUB_TOKEN", - do: {name, n} + refs = for {name, n} <- secret_refs(job_lines), name != "GITHUB_TOKEN", do: {name, n} case refs do [] -> @@ -299,11 +295,15 @@ defmodule Hypatia.Rules.ResearchExtensions do end) end - # Every secret a line reads inside ${{ … }}: 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. + # 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 @@ -312,12 +312,22 @@ defmodule Hypatia.Rules.ResearchExtensions do # counts, not a property named secrets (github.event.x.secrets.Y). @expression ~r/\$\{\{((?:'(?:[^']|'')*'|[^'}]|\}(?!\}))*)\}\}/ @secret_ref ~r/(? Enum.drop(1) |> Enum.find(&(&1 != "")), - name != nil, - do: String.upcase(name) + defp secret_refs(job_lines) do + text = Enum.map_join(job_lines, "\n", &elem(&1, 0)) + + # {byte offset where each kept line starts, its physical line number} + {starts, _} = + Enum.map_reduce(job_lines, 0, fn {line, n}, at -> {{at, n}, at + byte_size(line) + 1} end) + + for [_, {body_at, body_len}] <- Regex.scan(@expression, text, return: :index), + [_ | groups] <- + Regex.scan(@secret_ref, binary_part(text, body_at, body_len), return: :index), + {at, len} = Enum.find(groups, {-1, 0}, fn {at, _} -> at >= 0 end), + at >= 0 do + at = body_at + at + {_, n} = starts |> Enum.take_while(fn {start, _} -> start <= at end) |> List.last() + {String.upcase(binary_part(text, at, len)), n} + end end defp job_id(header) do diff --git a/test/deed_test.exs b/test/deed_test.exs index a440d2d4..babfd9a3 100644 --- a/test/deed_test.exs +++ b/test/deed_test.exs @@ -84,6 +84,57 @@ defmodule Hypatia.DeedTest do 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, as the gate and the template allow" do + # deed.abnf has no separator before a list's first value; deed_lint and + # rsr-template-repo_chora.deed (`:purpose (` then a newline) do. + 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 "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))|) @@ -134,6 +185,14 @@ defmodule Hypatia.DeedTest do 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") diff --git a/test/fixtures/deed_egress/README.adoc b/test/fixtures/deed_egress/README.adoc index 45ba33ac..acdf635c 100644 --- a/test/fixtures/deed_egress/README.adoc +++ b/test/fixtures/deed_egress/README.adoc @@ -7,3 +7,8 @@ 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/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/research_extensions_test.exs b/test/research_extensions_test.exs index 80efa7e7..acacc6d0 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -516,6 +516,56 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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 "a property named secrets is not the secrets context" do repo = create_repo_with_workflow( From f16545cd1e89dcf55d611cbd0b179076c095bde9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:31:05 +0000 Subject: [PATCH 11/18] Deed reader: cite deed.abnf v1.2.1 for the list rule The owner ruled the list departure into the grammar (standards#1359: a separator may open a list), so the reader no longer departs from it. Comments, moduledoc and CHANGELOG say so; no behaviour change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- CHANGELOG.adoc | 14 +++++++++----- lib/hypatia/deed.ex | 14 +++++++------- test/deed_test.exs | 5 ++--- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8be9e24a..9ac8153e 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -24,11 +24,15 @@ to declare egress instead of adopting harden-runner everywhere. RE001 now: 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); -* names the job and gives the exact `(job …)` entry in its fix text. - -`Hypatia.Deed` is a new reader for DEED documents and the `(egress …)` clause -profile. `test/deed_test.exs` checks it against the standards fixtures, so it -accepts and rejects exactly what `deed_lint.js --egress` does. +* 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, in property + or index form, outside string literals. + +`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 diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index dd70e488..3e49706b 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -4,7 +4,7 @@ defmodule Hypatia.Deed do @moduledoc """ Reader for DEED documents (`metadatastician/standards` - `1-formats/deed/spec/abnf/deed.abnf` v1.2.0) and for the `(egress …)` clause + `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 @@ -20,8 +20,9 @@ defmodule Hypatia.Deed do `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. The one departure, a separator before a - list's first value, is noted at `list_open/1`. + 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}`." @@ -376,10 +377,9 @@ defmodule Hypatia.Deed do defp uuid8(err), do: err - # list = "(" [value *(token-sep value)] [token-sep] ")". One departure from - # deed.abnf: a separator may come before the first value, as the gate allows - # and the estate's worked-model deed (rsr-template-repo) writes every - # multi-line list. It cannot change what a value is. + # 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} diff --git a/test/deed_test.exs b/test/deed_test.exs index babfd9a3..b4b33c73 100644 --- a/test/deed_test.exs +++ b/test/deed_test.exs @@ -117,9 +117,8 @@ defmodule Hypatia.DeedTest do assert {:error, _} = Deed.parse(";; SPDX-License-Identifier: MPL-2.0" <> form) end - test "a list may open with a separator, as the gate and the template allow" do - # deed.abnf has no separator before a list's first value; deed_lint and - # rsr-template-repo_chora.deed (`:purpose (` then a newline) do. + 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"))|) From 37afd40b9e5ff494609e5ab3aa098d98275f3704 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 02:34:20 +0000 Subject: [PATCH 12/18] RE001: secrets context case-insensitive; computed keys cannot be declared Two review findings, both ways a declaration could cover a job that reads more than it lists: - GitHub matches context names without regard to case, so SECRETS.X and Secrets['X'] read X. The top-level context is now matched that way. - Any other use of the context reads secrets whose names are not in the text: secrets[env.NAME], toJSON(secrets), secrets.*. Such a job gets its own finding, pointing at the line, that no egress declaration can satisfy; harden-runner still does. A job that reads secrets only that way is now a finding too, where the old rule did not see it. RE001 counts on hypatia, standards, the template and choreographic-types are unchanged (9 / 15 / 0 / 1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/rules/research_extensions.ex | 54 ++++++++++++++++++---- test/research_extensions_test.exs | 77 +++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 7905f5ad..5a0c09ea 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -237,6 +237,10 @@ defmodule Hypatia.Rules.ResearchExtensions do _ when job.hardened? -> [] + # A key computed at run time cannot be listed, so no declaration covers it. + found when job.dynamic_line != nil -> + [re001_dynamic(job, deed_file, match?({:ok, _}, found))] + {:ok, secrets} when secrets == job.secrets -> [] @@ -287,7 +291,8 @@ defmodule Hypatia.Rules.ResearchExtensions do workflow: rel, id: job_id(header), line: first_line, - secrets: MapSet.new(refs, &elem(&1, 0)), + 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) } ] @@ -309,9 +314,14 @@ defmodule Hypatia.Rules.ResearchExtensions do # 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). + # 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), - [_ | groups] <- + [{m_at, _} | groups] <- Regex.scan(@secret_ref, binary_part(text, body_at, body_len), return: :index), - {at, len} = Enum.find(groups, {-1, 0}, fn {at, _} -> at >= 0 end), - at >= 0 do - at = body_at + at - {_, n} = starts |> Enum.take_while(fn {start, _} -> start <= at end) |> List.last() - {String.upcase(binary_part(text, at, len)), n} + # 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 @@ -361,6 +377,26 @@ defmodule Hypatia.Rules.ResearchExtensions do } end + defp re001_dynamic(job, deed_file, declared?) do + %{ + rule: "RE001", + file: job.workflow, + severity: :warn, + 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" <> + if(declared?, do: "; its entry in #{deed_file} does not cover it", else: ""), + action: :report, + detail: %{ + 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) diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index acacc6d0..d08c2f9b 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -566,6 +566,83 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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( From 020da4be8c79ba550fb0cdcb4b5bda2e3fc5614c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:07:06 +0000 Subject: [PATCH 13/18] RE001: combine #966's exposure model with the ADR-009 declaration The merge of main (#966) into this branch, 7b1e5f9, left re001_missing_harden_runner/1 spliced from both versions; the module did not compile. This rebuilds it from main's version with #964 on top, as the two sessions agreed and the owner ruled on 2026-10-27: - Scope (ADR-009): every locally run job that reads a real secret. - Satisfied by harden-runner, or by a deed entry whose :secrets equal the secrets the job reads, exposed or not. - An undeclared job is :high when the credential shares a step with fetched or untrusted code (#966's step, env and trigger model), and :warn otherwise, with or without an exposure. The exposure list is in the reason and in detail.exposures. - Drift and stale stay :warn. A computed-key read stays undeclarable. - Secrets are read by #964's ${{ }} reader; #966's credential_line? is gone, so the exposure model sees the same secrets the deed is compared against (index form, any case, folded expressions). Tests: main's #966 suite runs unchanged except where it asserted no finding for a job without an exposure: those 9 now assert a :warn finding with no exposures, which still checks that each decoy is not mistaken for one. #964's deed suite is restored. 145 tests pass (research_extensions_test + deed_test), kyaml_workflow_test 10/10. RE001 on hypatia, standards, template, choreographic-types: 9/15/0/1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- CHANGELOG.adoc | 10 +- lib/rules/research_extensions.ex | 426 ++++++++++++++++------- test/research_extensions_test.exs | 546 +++++++++++++++++++++++++++++- 3 files changed, 847 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 9ac8153e..917285ef 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -26,8 +26,14 @@ to declare egress instead of adopting harden-runner everywhere. RE001 now: declarations (the job is gone, runs elsewhere, or reads no real secret); * 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, in property - or index form, outside string literals. + 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 diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 74de2268..a33fc7c1 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 @@ -257,117 +274,284 @@ defmodule Hypatia.Rules.ResearchExtensions do {:error, file, reason} -> {file, %{}, "the repo deed declares nothing (#{reason})"} end - # 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) + jobs = + repo_path + |> workflow_files() + |> Enum.flat_map(fn path -> re001_jobs(path, Path.relative_to(path, repo_path)) end) - untrusted_events = - active_lines - |> workflow_events() - |> Enum.filter(&(&1 in @re001_untrusted_events)) + job_findings = + Enum.flat_map(jobs, fn job -> + case Map.fetch(declared, {job.workflow, job.id}) do + _ when job.hardened? -> + [] - 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 + # A key computed at run time cannot be listed, so no declaration covers it. + found when job.dynamic_line != nil -> + [re001_dynamic(job, deed_file, match?({:ok, _}, found))] - 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 = - Enum.find_value(steps, fn step -> - credential = - Enum.find(step, &credential_line?/1) || - Enum.find(job_env_credentials, &(not overrides_env?(step, &1))) + {:ok, secrets} when secrets == job.secrets -> + [] - credential && step_code_exposures(step) != [] && credential - end) + {:ok, secrets} -> + [re001_drift(job, deed_file, secrets)] - {severity, {_source, line}} = - case shared do - nil -> {:warn, first_credential} - credential -> {:high, credential} - end + :error -> + [re001_undeclared(job, deed_file, deed_note)] + end + end) + + # Every declaration must still describe a local job that reads a real secret. + live = MapSet.new(jobs, &{&1.workflow, &1.id}) + + stale_findings = + declared + |> Map.keys() + |> Enum.reject(&MapSet.member?(live, &1)) + |> Enum.sort() + |> Enum.map(&re001_stale(&1, deed_file)) - # The locally executed jobs of one workflow that read a real secret. + 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. - path - |> read_workflow() - |> String.split("\n") - |> Enum.with_index(1) - |> Enum.reject(fn {line, _} -> String.starts_with?(String.trim_leading(line), "#") end) + 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_events() + |> Enum.filter(&(&1 in @re001_untrusted_events)) + + 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), name != "GITHUB_TOKEN", do: {name, n} + refs = + for {name, n} <- secret_refs(job_lines), 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 own `env:` is in every step's environment. + job_env_credentials = job_env_lines(job_lines) |> Enum.filter(credential?) + + job_untrusted_events = + case job_event_gate(job_lines) do + nil -> untrusted_events + gated -> Enum.filter(untrusted_events, &(&1 in gated)) + end + + # A step that both holds the credential and runs fetched or head code + # hands the credential straight to that code. + shared_line = + Enum.find_value(steps, fn step -> + credential = + Enum.find(step, credential?) || + Enum.find(job_env_credentials, &(not overrides_env?(step, &1))) + + credential && step_code_exposures(step) != [] && elem(credential, 1) + 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), + 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 + + defp re001_severity(job), do: if(job.shared?, 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) + + %{ + rule: "RE001", + file: job.workflow, + severity: :warn, + 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 + ), + "; " + ), + action: :report, + detail: %{ + 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_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) diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index 8feff951..c111a811 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -219,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] @@ -234,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 @@ -289,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 @@ -332,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 @@ -380,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 @@ -417,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 @@ -479,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) @@ -581,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 @@ -615,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 @@ -689,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 @@ -766,6 +787,507 @@ 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" + ) + + write_deed(repo, "(egress #{smtp_job(~s|"SMTP_HOST"|)})") + + assert ResearchExtensions.re001_missing_harden_runner(repo) == [] + File.rm_rf!(repo) + 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 "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 ────────────────────────────────────────────────────────── From 07d7afb5693149ad9b9c8d245825bdbd9006828c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:10:37 +0000 Subject: [PATCH 14/18] Deed reader: the gate's filename, dispatch and boolean-word checks Three checks deed_lint makes that the reader did not, so a deed the gate refuses could still declare egress: - the of _chora.deed is deed.abnf's: letters, digits, '-', '.', '_', at least one ("bad name_chora.deed", "_chora.deed" are refused); - a _chora.deed must parse as a repo-deed; - a field's value may not be true, false, yes or no: booleans are #t and #f. 148 tests pass; the differential corpora still show no divergence from deed.abnf; every real deed in the four local clones is still accepted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/hypatia/deed.ex | 29 ++++++++++++++++++++++++----- test/deed_test.exs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index 3e49706b..17b98fd2 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -65,9 +65,17 @@ defmodule Hypatia.Deed do |> Enum.filter(&(String.ends_with?(&1, "_chora.deed") and &1 != "estate_chora.deed")) |> Enum.sort() |> case do - [] -> :none - [one] -> {:ok, Path.join(repo_path, one)} - many -> {:error, "more than one repo deed: #{Enum.join(many, ", ")}"} + [] -> + :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, _} -> @@ -96,6 +104,10 @@ defmodule Hypatia.Deed do 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 @@ -308,12 +320,19 @@ defmodule Hypatia.Deed do end end - # field = keyword token-sep value + # 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 - {:ok, {:field, kw, value}, rest} + 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 diff --git a/test/deed_test.exs b/test/deed_test.exs index b4b33c73..c2ed3f75 100644 --- a/test/deed_test.exs +++ b/test/deed_test.exs @@ -134,6 +134,16 @@ defmodule Hypatia.DeedTest do 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))|) @@ -224,5 +234,34 @@ defmodule Hypatia.DeedTest do 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}, + {"é_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) + 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 From 19671c1c16c1eab60e22072a28a017d8370b3f23 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:19:13 +0000 Subject: [PATCH 15/18] RE001: drift keeps the job's severity; harden-runner match ignores case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings: - An incomplete declaration downgraded a job whose credential shares a step with fetched code from :high (undeclared) to :warn (drift). Drift now takes the job's own severity and carries its exposures, as the undeclared and computed-key findings do. Stale stays :warn: there is no job to rate. - GitHub resolves an action's owner/repo without regard to case, so Step-Security/Harden-Runner@… installs the hardener; the match now ignores case in the action path (the uses: key stays exact). 150 tests pass; RE001 on the four real trees unchanged (9/15/0/1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/rules/research_extensions.ex | 12 ++++++--- test/research_extensions_test.exs | 42 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index a33fc7c1..8a0c6a77 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -505,10 +505,12 @@ defmodule Hypatia.Rules.ResearchExtensions 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: :warn, + severity: re001_severity(job), line: job.line, reason: "job `#{job.id}` in #{job.workflow} no longer matches its egress declaration in " <> @@ -522,9 +524,10 @@ defmodule Hypatia.Rules.ResearchExtensions do &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)}), " <> @@ -889,8 +892,11 @@ defmodule Hypatia.Rules.ResearchExtensions do |> Enum.min(fn -> nil 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/research_extensions_test.exs b/test/research_extensions_test.exs index c111a811..a322f95c 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -1162,6 +1162,48 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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 "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( From 882fe514db200ab4a2e8fa9e3e80a14cded89d01 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:20:33 +0000 Subject: [PATCH 16/18] RE001: secrets in the workflow's top-level env are read by every job A secret in the workflow-level `env:` is inherited by every job and every step, but RE001 read only each job's own lines. A deed entry omitting the inherited secret then matched, and a job reading nothing of its own was not in scope at all. The top-level env's secret references now join each local job's set, so the declaration comparison sees them, and they count like a job-level env credential for the shared-step (:high) check. A job with no secret of its own but an inherited one is reported, pointing at the env line. 151 tests pass; RE001 on the four real trees unchanged (9/15/0/1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/rules/research_extensions.ex | 27 ++++++++++++++++++--- test/research_extensions_test.exs | 40 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 8a0c6a77..8d74af5f 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -330,6 +330,11 @@ defmodule Hypatia.Rules.ResearchExtensions do |> 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 @@ -337,7 +342,9 @@ defmodule Hypatia.Rules.ResearchExtensions do |> Enum.filter(&direct_job_field?(&1, "runs-on")) |> Enum.flat_map(fn [{header, _} | _] = job_lines -> refs = - for {name, n} <- secret_refs(job_lines), name not in @re001_ambient_secrets, do: {name, n} + for {name, n} <- secret_refs(job_lines) ++ workflow_refs, + name not in @re001_ambient_secrets, + do: {name, n} case refs do [] -> @@ -348,8 +355,9 @@ defmodule Hypatia.Rules.ResearchExtensions do 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 own `env:` is in every step's environment. - job_env_credentials = job_env_lines(job_lines) |> Enum.filter(credential?) + # 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 @@ -658,6 +666,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) <- diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index a322f95c..388646e3 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -1189,6 +1189,46 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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(""" From 4242d2a1090b50903c3fdc642e4f4d51dc1e5ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:24:20 +0000 Subject: [PATCH 17/18] RE001: a declaration is checked even when harden-runner satisfies the job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declaration attests a fact, and a hardened job skipped every check: - a hardened job whose declared :secrets differ from what it reads is now drift, at :warn (harden-runner contains the job); - a declaration claiming :enforcement harden-runner-audit/-block on a job that does not install harden-runner is a finding. Without it, removing the hardener left the declaration clearing the job while claiming block enforcement. Hypatia.Deed now returns each entry's :enforcement. Also fixes a test of my own that leaked: the filename-stem test wrote "é_chora.deed" and never removed it, which Erlang could not delete in a later run reusing the tmp path. The ASCII regex already covers that case; the test now cleans up after each name. 152 tests pass (4 runs stable); RE001 on the four real trees unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- CHANGELOG.adoc | 4 +- lib/hypatia/deed.ex | 6 +-- lib/rules/research_extensions.ex | 67 +++++++++++++++++++++++-------- test/deed_test.exs | 8 ++-- test/research_extensions_test.exs | 44 +++++++++++++++++++- 5 files changed, 105 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 917285ef..a6ccd388 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -23,7 +23,9 @@ to declare egress instead of adopting harden-runner everywhere. RE001 now: (`_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); + 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 diff --git a/lib/hypatia/deed.ex b/lib/hypatia/deed.ex index 17b98fd2..ac633247 100644 --- a/lib/hypatia/deed.ex +++ b/lib/hypatia/deed.ex @@ -87,8 +87,8 @@ defmodule Hypatia.Deed do 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}`, `:none` when the repo has no - deed, or `{:error, deed_file, reason}` when the deed or the clause cannot be + `%{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 @@ -206,7 +206,7 @@ defmodule Hypatia.Deed do 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)} + job = %{workflow: workflow, job: job, secrets: MapSet.new(secrets), enforcement: enf} {:cont, {:ok, [job | jobs]}} else _ -> diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 8d74af5f..3e25bf72 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -269,7 +269,7 @@ defmodule Hypatia.Rules.ResearchExtensions do def re001_missing_harden_runner(repo_path) do {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.secrets}), nil} + {: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 @@ -282,21 +282,34 @@ defmodule Hypatia.Rules.ResearchExtensions do job_findings = Enum.flat_map(jobs, fn job -> case Map.fetch(declared, {job.workflow, job.id}) do - _ when job.hardened? -> - [] - - # A key computed at run time cannot be listed, so no declaration covers it. - found when job.dynamic_line != nil -> - [re001_dynamic(job, deed_file, match?({:ok, _}, found))] - - {:ok, secrets} when secrets == job.secrets -> - [] - - {:ok, secrets} -> - [re001_drift(job, deed_file, secrets)] - :error -> - [re001_undeclared(job, deed_file, deed_note)] + 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 = + if decl.enforcement != "none" and not job.hardened?, + do: [re001_enforcement(job, deed_file, decl.enforcement)], + else: [] + + dynamic ++ drift ++ enforcement end end) @@ -451,7 +464,8 @@ defmodule Hypatia.Rules.ResearchExtensions do if(job.shared?, do: " in the same step", else: "") end - defp re001_severity(job), do: if(job.shared?, do: :high, else: :warn) + # 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 = @@ -544,6 +558,27 @@ defmodule Hypatia.Rules.ResearchExtensions do } 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 does not install `step-security/harden-runner`" <> + exposure_note(job), + action: :report, + detail: %{ + exposures: job.exposures, + fix: + "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 + defp re001_stale({workflow, id}, deed_file) do %{ rule: "RE001", diff --git a/test/deed_test.exs b/test/deed_test.exs index c2ed3f75..2382eb3e 100644 --- a/test/deed_test.exs +++ b/test/deed_test.exs @@ -210,7 +210,8 @@ defmodule Hypatia.DeedTest do assert notify == %{ workflow: ".github/workflows/push-email-notify.yml", job: "notify", - secrets: MapSet.new(~w(SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS)) + secrets: MapSet.new(~w(SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS)), + enforcement: "none" } assert sonar.secrets == MapSet.new(["SONAR_TOKEN"]) @@ -239,8 +240,7 @@ defmodule Hypatia.DeedTest do for {name, ok?} <- [ {"my.project-x_chora.deed", true}, {"bad name_chora.deed", false}, - {"_chora.deed", false}, - {"é_chora.deed", false} + {"_chora.deed", false} ] do repo = Path.join(@tmp_dir, "deed_stem_#{System.unique_integer([:positive])}") File.rm_rf!(repo) @@ -251,6 +251,8 @@ defmodule Hypatia.DeedTest do 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 diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index 388646e3..e6723378 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -956,12 +956,54 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do "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 ResearchExtensions.re001_missing_harden_runner(repo) == [] + 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"), + "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 quoted job key matches its declaration" do repo = create_repo_with_workflow( From 5b73e802271d85720d5a0c8409e0cbb45d9de8bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 03:26:46 +0000 Subject: [PATCH 18/18] RE001: a declared block enforcement needs egress-policy: block A declaration claiming :enforcement harden-runner-block on a job whose harden-runner step runs egress-policy: audit, or sets none (the action defaults to audit), is a false attestation of the same kind as a removed hardener, and is now a finding. The policy is read from the step's block or flow `with:`. The fix text says to set block or declare harden-runner-audit. 153 tests pass (2 runs); RE001 on the four real trees unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J4QvDithtn5Xk5MbSPm4Px --- lib/rules/research_extensions.ex | 53 ++++++++++++++++++++++++++----- test/research_extensions_test.exs | 42 +++++++++++++++++++++++- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/lib/rules/research_extensions.ex b/lib/rules/research_extensions.ex index 3e25bf72..7966247f 100644 --- a/lib/rules/research_extensions.ex +++ b/lib/rules/research_extensions.ex @@ -305,9 +305,17 @@ defmodule Hypatia.Rules.ResearchExtensions do else: [] enforcement = - if decl.enforcement != "none" and not job.hardened?, - do: [re001_enforcement(job, deed_file, decl.enforcement)], - else: [] + 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 @@ -397,6 +405,7 @@ defmodule Hypatia.Rules.ResearchExtensions do 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 } @@ -566,15 +575,24 @@ defmodule Hypatia.Rules.ResearchExtensions do line: job.line, reason: "job `#{job.id}` in #{job.workflow} is declared in #{deed_file} with " <> - ":enforcement #{enforcement}, but does not install `step-security/harden-runner`" <> - exposure_note(job), + ":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: - "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." + 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 @@ -948,6 +966,25 @@ 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. diff --git a/test/research_extensions_test.exs b/test/research_extensions_test.exs index e6723378..1bc8da76 100644 --- a/test/research_extensions_test.exs +++ b/test/research_extensions_test.exs @@ -986,7 +986,10 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do hardened = create_repo_with_workflow( - workflow.(" - uses: step-security/harden-runner@v2\n"), + workflow.( + " - uses: step-security/harden-runner@v2\n" <> + " with:\n egress-policy: block\n" + ), "notify.yml" ) @@ -1004,6 +1007,43 @@ defmodule Hypatia.Rules.ResearchExtensionsTest do 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(