From a5f5adaff57b75bca9e7a001c3e964988e4e3313 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Tue, 14 Jul 2026 10:43:14 -0500 Subject: [PATCH] fix(e2): harden portable M0 release checks Use a canonical private temporary scratch root for the direct OCM lab, retain explicit override controls, and prove the lifecycle under a symlinked TMPDIR. Strengthen the OCI Kind contract to decode its version payload and correct the associated operator evidence. GSTACK-Checkpoint: 2026-07-14/release-hardening#1 Signed-off-by: Gnani Rahul --- Makefile | 2 +- docs/experiments/M0-ocm-falsification.md | 20 ++++--- hack/experiments/m0-ocm-falsification.sh | 31 ++++++++-- ...-07-13-e2-direct-konnectivity-transport.md | 2 +- sessions/2026-07-14-release-hardening.md | 59 +++++++++++++++++++ tests/e2e/oci_image_kind_test.go | 40 ++++++++++++- .../m0_ocm_falsification_safety_test.sh | 25 ++++++++ 7 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 sessions/2026-07-14-release-hardening.md diff --git a/Makefile b/Makefile index a190737..e56f921 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ HELM ?= helm GORELEASER ?= goreleaser DOCKER ?= docker KUBECTL ?= kubectl -OCM_SCRATCH_ROOT ?= /Volumes/EXTENDED/tmp/sith-m0 +OCM_SCRATCH_ROOT ?= $(shell python3 -c 'import os; print(os.path.join(os.path.realpath(os.environ.get("TMPDIR", "/tmp")), "sith-m0-{}".format(os.getuid()), "lab"))') OCM_PREFIX ?= sith-m0 KIND_NODE_IMAGE ?= kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5 diff --git a/docs/experiments/M0-ocm-falsification.md b/docs/experiments/M0-ocm-falsification.md index 0998aaf..a3cf8ed 100644 --- a/docs/experiments/M0-ocm-falsification.md +++ b/docs/experiments/M0-ocm-falsification.md @@ -135,12 +135,13 @@ including `--force-internal-endpoint-lookup` for local kind clusters. ## Reproduce -The default scratch root is `/Volumes/EXTENDED/tmp/sith-m0`; the runner refuses another -filesystem unless the operator explicitly opts in. The path must be canonical, must not already -exist for a fresh run, and is deleted only when its regular ownership marker belongs to the -current user. The runner verifies the entered parent's device/inode against the validated path, -enters it once, and uses relative child paths from that held working directory, so racing or later -renaming a writable ancestor cannot redirect creation or cleanup. +The default scratch root is a private canonical `${TMPDIR:-/tmp}/sith-m0-/lab`; the runner +creates its parent with mode `0700`. Another non-EXTENDED path requires the operator to set +`SITH_M0_ALLOW_NON_EXTENDED=1` deliberately. The path must be canonical, must not already exist +for a fresh run, and is deleted only when its regular ownership marker belongs to the current +user. The runner verifies the entered parent's device/inode against the validated path, enters it +once, and uses relative child paths from that held working directory, so racing or later renaming a +writable ancestor cannot redirect creation or cleanup. It uses a dedicated kubeconfig and isolated Helm state, requires a local Unix-socket Docker endpoint, gives the image builder only a fixture-only context, and never reads or modifies the user's kubeconfig. @@ -190,9 +191,10 @@ through the narrow `get` reader. It requires direct TLS-verified snapshots from MSA-token `Forbidden` for a cluster-wide Secrets list, and a replacement projection with a changed token before a subsequent snapshot. No token, CA, response body, or port-forward output is printed. -To support that product read boundary, each M0 `sith-reader` gets only cluster-wide `list` on -Pods, Deployments, and Rollouts. The existing namespaced service-proxy Role is separate; there is -still no grant for Secrets, Nodes, writes, watches, or hub API access. +To support that product read boundary, each M0 `sith-reader` gets cluster-wide `list` on Pods, +Deployments, and Rollouts plus namespaced `get/list` on Pods, Services, and `services/proxy` in +`sith-demo`. Those are the complete combined grants; there is still no grant for Secrets, Nodes, +writes, watches, or hub API access. ## What the runner proves diff --git a/hack/experiments/m0-ocm-falsification.sh b/hack/experiments/m0-ocm-falsification.sh index 97f20cd..bdbccfb 100755 --- a/hack/experiments/m0-ocm-falsification.sh +++ b/hack/experiments/m0-ocm-falsification.sh @@ -32,7 +32,16 @@ readonly SPOKE_B_NAME="${LAB_PREFIX}-spoke-b" readonly HUB_CONTEXT="kind-${HUB_NAME}" readonly SPOKE_A_CONTEXT="kind-${SPOKE_A_NAME}" readonly SPOKE_B_CONTEXT="kind-${SPOKE_B_NAME}" -readonly SCRATCH_ROOT="${SITH_M0_SCRATCH_ROOT:-/Volumes/EXTENDED/tmp/sith-m0}" +DEFAULT_TMPDIR="$(${PYTHON_BIN} - "${TMPDIR:-/tmp}" <<'PY' +import os +import sys + +print(os.path.realpath(sys.argv[1])) +PY +)" +readonly DEFAULT_TMPDIR +readonly DEFAULT_SCRATCH_ROOT="${DEFAULT_TMPDIR}/sith-m0-${EUID}/lab" +readonly SCRATCH_ROOT="${SITH_M0_SCRATCH_ROOT:-${DEFAULT_SCRATCH_ROOT}}" SCRATCH_PARENT="$(dirname "${SCRATCH_ROOT}")" readonly SCRATCH_PARENT SCRATCH_NAME="$(basename "${SCRATCH_ROOT}")" @@ -66,7 +75,7 @@ Commands: Environment: SITH_M0_KEEP_CLUSTERS=1 Retain clusters and scratch after `run`. - SITH_M0_SCRATCH_ROOT= Scratch path; defaults to EXTENDED storage. + SITH_M0_SCRATCH_ROOT= Scratch path; defaults to a private TMPDIR path. SITH_M0_ALLOW_NON_EXTENDED=1 Permit an explicit non-EXTENDED scratch path. SITH_M0_PREFIX= Override the disposable kind cluster prefix. @@ -125,8 +134,8 @@ PY case "${SCRATCH_ROOT}" in /Volumes/EXTENDED/*) ;; *) - [[ "${SITH_M0_ALLOW_NON_EXTENDED:-0}" == "1" ]] || - die "scratch must remain on /Volumes/EXTENDED (or explicitly set SITH_M0_ALLOW_NON_EXTENDED=1)" + [[ "${SCRATCH_ROOT}" == "${DEFAULT_SCRATCH_ROOT}" || "${SITH_M0_ALLOW_NON_EXTENDED:-0}" == "1" ]] || + die "scratch must use the private default or explicitly set SITH_M0_ALLOW_NON_EXTENDED=1" ;; esac @@ -160,6 +169,14 @@ validate_scratch_parent() { scratch_directory_identity "${SCRATCH_PARENT}" >/dev/null } +prepare_default_scratch_parent() { + [[ "${SCRATCH_ROOT}" == "${DEFAULT_SCRATCH_ROOT}" ]] || return 0 + [[ ! -e "${SCRATCH_PARENT}" && ! -L "${SCRATCH_PARENT}" ]] || return 0 + + umask 077 + mkdir -p -m 0700 -- "${SCRATCH_PARENT}" +} + enter_scratch_parent() { local entered_identity local expected_identity @@ -253,6 +270,7 @@ check_tools() { } prepare_scratch() { + prepare_default_scratch_parent enter_scratch_parent [[ ! -e "${SCRATCH_NAME}" && ! -L "${SCRATCH_NAME}" ]] || die "scratch root already exists; use verify or cleanup before a fresh run" @@ -289,6 +307,9 @@ delete_clusters() { } remove_scratch() { + if [[ "${SCRATCH_ROOT}" == "${DEFAULT_SCRATCH_ROOT}" && ! -e "${SCRATCH_PARENT}" && ! -L "${SCRATCH_PARENT}" ]]; then + return 0 + fi enter_scratch_parent [[ -e "${SCRATCH_NAME}" || -L "${SCRATCH_NAME}" ]] || return 0 validate_owned_scratch @@ -859,6 +880,7 @@ run_lab() { local started_at local finished_at started_at="$(date +%s)" + prepare_default_scratch_parent enter_scratch_parent check_tools ensure_lab_absent @@ -878,7 +900,6 @@ run_lab() { } cleanup_lab() { - enter_scratch_parent check_tools delete_clusters || die "cluster deletion failed; retaining scratch for recovery" remove_scratch diff --git a/sessions/2026-07-13-e2-direct-konnectivity-transport.md b/sessions/2026-07-13-e2-direct-konnectivity-transport.md index e408a58..3086073 100644 --- a/sessions/2026-07-13-e2-direct-konnectivity-transport.md +++ b/sessions/2026-07-13-e2-direct-konnectivity-transport.md @@ -12,7 +12,7 @@ cross the `hubfleet.Transport` seam. [S] Scope: `internal/hubocm`, the M0 experiment and its safety suite, narrowly reviewed privacy boundary exceptions, dependencies, and operator-facing documentation. The ClusterGateway-specific -#103/#104 route remains out of scope and blocked pending an official upstream release. +Issues #103 and #104 remain out of scope and blocked pending an official upstream release. [A] Action: chose the released ClusterProxy `0.10.0`-matched Konnectivity client `v0.31.2`, rather than an unreleased ClusterGateway fix or a custom agent/tunnel. The adapter treats diff --git a/sessions/2026-07-14-release-hardening.md b/sessions/2026-07-14-release-hardening.md new file mode 100644 index 0000000..da2c17c --- /dev/null +++ b/sessions/2026-07-14-release-hardening.md @@ -0,0 +1,59 @@ +# Session — 2026-07-14 — release hardening + +**Issue:** [#143](https://github.com/ArdurAI/sith/issues/143) +**Branch:** `gnanirahulnutakki/fix/release-hardening` +**Base:** `origin/dev` at `ba102f467415db3e309cbf35fe7b02f88929c593` + +## [G] Goal + +Resolve the portable-scratch, OCI assertion, and operator-documentation findings discovered during +the governed-read release promotion before cutting a stable public tag. + +## [S] Scope + +- Replace the machine-specific M0 scratch default with a canonical private per-UID temporary path. +- Preserve the explicit opt-in for arbitrary non-EXTENDED roots, private-parent validation, held + directory identity, marker-owned cleanup, and local-Docker requirement. +- Decode the OCI Job's JSON and require a non-empty `version` field. +- Correct the rendered issue reference and document the complete combined M0 reader grants. + +## [A] Analysis and red-team checks + +- The first real M0 run exposed macOS `$TMPDIR` aliasing through `/var` to `/private/var`. The + harness correctly rejected that non-canonical path, so the default is canonicalized rather than + weakening the no-symlink rule. +- The second run exposed that the default private parent was prepared after the first entry + validation. Preparation now happens before entry; the helper remains idempotent for focused + safety tests. +- The generated default is the only non-EXTENDED root accepted without an opt-in. Any other + non-EXTENDED override still requires `SITH_M0_ALLOW_NON_EXTENDED=1` and then must pass the same + canonical, owner, mode, and inode checks. +- `CREATE INDEX` in migration `0006` remains transactional by design: the indexed table is created + empty in that same migration, so no pre-existing workload can be blocked by that index creation. +- Authentication refusal delivery remains separately tracked in [#140](https://github.com/ArdurAI/sith/issues/140): the existing arbitrary `slog` sink contract cannot guarantee both nonblocking delivery and leak-free shutdown without a dedicated lifecycle-owned transport. + +## [T] Tests and evidence + +- Shell safety suite: PASS — `bash -n hack/experiments/m0-ocm-falsification.sh` and + `make test-scripts` (19 assertions), including the generated default lifecycle and a symlinked + temporary-directory canonicalization case. +- OCI assertion unit: PASS — `go test -race -count=1 -tags='e2e kind' -run + '^TestValidateVersionOutput$' ./tests/e2e`; valid JSON without `version` and empty versions fail. +- Real M0 integration: PASS — `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make e2e-ocm` created + a hub and two spokes under the portable default, proved scoped reads, Secrets/Nodes denial, + outbound-only controls, credential replacement, direct adapter and runtime tests, then removed + all three clusters. M0 elapsed time was 186 seconds. +- Real multi-cluster Kind integration: PASS — `KIND=/Volumes/EXTENDED/MacData/tools/bin/kind make + e2e-kind` in 177.961 seconds. +- Repository CI: PASS — `make ci`, including race tests, static analysis (0 issues), dependency + vulnerability scan (no vulnerabilities), shell safety, UI latency, and tagged binary e2e. +- Isolation and release: PASS — `make e2e-isolation` with PostgreSQL/RLS coverage and the fixed + 50,000-execution workspace fuzz campaign; `make release-check` rebuilt and verified all four + Darwin/Linux archives twice, produced SPDX SBOMs, and rendered the formula. +- Independent local staged-diff review: one stale error-message finding, fixed and rerun through + focused syntax, safety, OCI unit, formatting, and whitespace checks. + +## [C] Checkpoint + +- README review found no required change. The source, documentation, tests, session evidence, and + validations are ready for a signed, DCO, GSTACK commit and a narrow PR into `dev`. diff --git a/tests/e2e/oci_image_kind_test.go b/tests/e2e/oci_image_kind_test.go index 61ec039..d363586 100644 --- a/tests/e2e/oci_image_kind_test.go +++ b/tests/e2e/oci_image_kind_test.go @@ -9,7 +9,6 @@ import ( "fmt" "os/exec" "runtime" - "strings" "testing" "time" @@ -111,7 +110,44 @@ func assertOCIImageJob(ctx context.Context, t *testing.T, kindBinary, clusterNam t.Fatalf("find OCI Job Pod on %s: %#v", clusterName, pods.Items) } output, err := client.CoreV1().Pods("default").GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{}).Do(ctx).Raw() - if err != nil || !json.Valid(output) || !strings.Contains(string(output), "\"version\"") { + if err == nil { + err = validateVersionOutput(output) + } + if err != nil { t.Fatalf("OCI Job output on %s = %q / %v", clusterName, output, err) } } + +func validateVersionOutput(output []byte) error { + var versionOutput struct { + Version string `json:"version"` + } + if err := json.Unmarshal(output, &versionOutput); err != nil { + return fmt.Errorf("decode version output: %w", err) + } + if versionOutput.Version == "" { + return fmt.Errorf("version output omitted a non-empty version") + } + return nil +} + +func TestValidateVersionOutput(t *testing.T) { + t.Parallel() + + for name, test := range map[string]struct { + output string + valid bool + }{ + "version": {output: `{"version":"v0.2.0"}`, valid: true}, + "unrelated version string": {output: `{"message":"version"}`}, + "empty version": {output: `{"version":""}`}, + "invalid JSON": {output: `{`}, + } { + t.Run(name, func(t *testing.T) { + err := validateVersionOutput([]byte(test.output)) + if (err == nil) != test.valid { + t.Fatalf("validateVersionOutput(%q) error = %v, want valid=%t", test.output, err, test.valid) + } + }) + } +} diff --git a/tests/scripts/m0_ocm_falsification_safety_test.sh b/tests/scripts/m0_ocm_falsification_safety_test.sh index d07d8ba..3c24e80 100644 --- a/tests/scripts/m0_ocm_falsification_safety_test.sh +++ b/tests/scripts/m0_ocm_falsification_safety_test.sh @@ -54,6 +54,31 @@ env SITH_M0_SCRATCH_ROOT="${owned_root}" SITH_M0_ALLOW_NON_EXTENDED=1 \ ' _ "${SCRIPT}" pass "owned scratch lifecycle preserves the valid control" +default_tmpdir="${TEST_ROOT}/default-tmp" +mkdir -m 0700 "${default_tmpdir}" +env TMPDIR="${default_tmpdir}" bash -c ' + source "$1" + [[ "${SCRATCH_ROOT}" == "${DEFAULT_SCRATCH_ROOT}" ]] + prepare_scratch + validate_owned_scratch + remove_scratch + [[ ! -e "${SCRATCH_ROOT}" ]] + ' _ "${SCRIPT}" +pass "portable default scratch lifecycle remains private and removable" + +default_tmpdir_alias="${TEST_ROOT}/default-tmp-alias" +ln -s "${default_tmpdir}" "${default_tmpdir_alias}" +env TMPDIR="${default_tmpdir_alias}" bash -c ' + source "$1" + [[ "${DEFAULT_TMPDIR}" == "${2}" ]] + [[ "${SCRATCH_ROOT}" == "${DEFAULT_SCRATCH_ROOT}" ]] + prepare_scratch + validate_owned_scratch + remove_scratch + [[ ! -e "${SCRATCH_ROOT}" ]] + ' _ "${SCRIPT}" "${default_tmpdir}" +pass "portable default canonicalizes a symlinked temporary directory" + unowned_root="${TEST_ROOT}/unowned-root" mkdir -p "${unowned_root}" printf 'keep\n' >"${unowned_root}/sentinel"