From 29c854599980c593184c70f95aa599d459d42026 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 19 Oct 2026 18:29:37 -0400 Subject: [PATCH 01/10] fix(plugins): nodes add checks for a deploy host before it contacts the host (D-1219) Add a pre-add hook point that runs on the destination alone, after ParseHostSpec and before the host-key scan. The deploy-host check registers there (identity from ssh -G and DNS only): a match without --allow-deploy-host is refused before any scan, probe or install, and an override is confirmed and audited once, there. Matches that need the host key or machine id are still caught at the add point. --- free/ci/internal/nodes/lifecycle/add.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/free/ci/internal/nodes/lifecycle/add.go b/free/ci/internal/nodes/lifecycle/add.go index c86ef8c77..db1b6daf9 100644 --- a/free/ci/internal/nodes/lifecycle/add.go +++ b/free/ci/internal/nodes/lifecycle/add.go @@ -87,6 +87,10 @@ func (d *Deps) Add(ctx context.Context, o AddOptions) (res AddResult, err error) } else if CodeOf(gerr) != CodeNotFound { return res, gerr } + // Before any contact: a hook that vets the destination alone (the deploy-host check, D-1219) runs here. + if err = RunHooks(ctx, PointPreAdd, HookEvent{NodeID: id, Project: d.Project, Flags: o.Flags, Dest: o.Dest}); err != nil { + return res, err + } installer, ok := ext.installer(orDefault(o.Installer, DefaultInstaller)) if !ok { return res, usage("unknown agent installer %q", o.Installer) From 769f2669e7d18b82498d4836eb92f5c9bad10f5e Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 19 Oct 2026 18:33:53 -0400 Subject: [PATCH 02/10] fix(plugins): pre-add override confirmation is bound to the matched source (D-1219) The confirmation taken before contact covers only the match it was given for; a different match found after contact is confirmed again. The installer name is validated before the pre-add hook, so no override is confirmed for an add that cannot proceed. --- free/ci/internal/nodes/lifecycle/add.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/free/ci/internal/nodes/lifecycle/add.go b/free/ci/internal/nodes/lifecycle/add.go index db1b6daf9..c86ef8c77 100644 --- a/free/ci/internal/nodes/lifecycle/add.go +++ b/free/ci/internal/nodes/lifecycle/add.go @@ -87,10 +87,6 @@ func (d *Deps) Add(ctx context.Context, o AddOptions) (res AddResult, err error) } else if CodeOf(gerr) != CodeNotFound { return res, gerr } - // Before any contact: a hook that vets the destination alone (the deploy-host check, D-1219) runs here. - if err = RunHooks(ctx, PointPreAdd, HookEvent{NodeID: id, Project: d.Project, Flags: o.Flags, Dest: o.Dest}); err != nil { - return res, err - } installer, ok := ext.installer(orDefault(o.Installer, DefaultInstaller)) if !ok { return res, usage("unknown agent installer %q", o.Installer) From 56ed044b40bef690c3652070e881a9b092177180 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 07:58:39 -0400 Subject: [PATCH 03/10] fix(ci): repair stale branch admission, Android and file-size gates --- .../dispatch/chokepoint_roles_test.go | 99 +++++++++++++++++++ free/ci/internal/dispatch/chokepoint_test.go | 16 ++- 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 free/ci/internal/dispatch/chokepoint_roles_test.go diff --git a/free/ci/internal/dispatch/chokepoint_roles_test.go b/free/ci/internal/dispatch/chokepoint_roles_test.go new file mode 100644 index 000000000..3c19fcd0d --- /dev/null +++ b/free/ci/internal/dispatch/chokepoint_roles_test.go @@ -0,0 +1,99 @@ +package dispatch + +import ( + "go/ast" + "go/token" + "go/types" + "path/filepath" + "strings" + "testing" + + "golang.org/x/tools/go/packages" +) + +// A role Grant is unrelated to lease admission. Resolve its object rather than +// allowlisting files or functions, which could also contain a lease bypass. +func isRoleGrant(obj types.Object) bool { + if obj == nil || obj.Name() != "Grant" || obj.Pkg() == nil { + return false + } + const base = "github.com/nself-org/plugins/free/ci/internal/" + switch obj.Pkg().Path() { + case base + "store": + _, ok := obj.(*types.TypeName) + return ok + case base + "roles": + fn, ok := obj.(*types.Func) + if !ok { + return false + } + sig, ok := fn.Type().(*types.Signature) + return ok && sig.Recv() != nil + } + return false +} + +func roleGrantPositions(t *testing.T, root string) map[string]map[token.Pos]bool { + t.Helper() + result := map[string]map[token.Pos]bool{} + // Planted modules deliberately contain unresolved identifiers. They retain + // the conservative syntactic scan, including their lease Grant violations. + if strings.Contains(filepath.ToSlash(root), "testdata/") { + return result + } + pkgs, err := packages.Load(&packages.Config{Dir: root, + Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports | packages.NeedDeps}, "./...") + if err != nil { + t.Fatal(err) + } + for _, pkg := range pkgs { + if len(pkg.Errors) != 0 { + t.Fatalf("load %s: %v", pkg.PkgPath, pkg.Errors) + } + for _, objects := range []map[*ast.Ident]types.Object{pkg.TypesInfo.Uses, pkg.TypesInfo.Defs} { + for id, obj := range objects { + if !isRoleGrant(obj) { + continue + } + pos := pkg.Fset.Position(id.Pos()) + rel, err := filepath.Rel(root, pos.Filename) + if err != nil { + t.Fatal(err) + } + path := filepath.Clean(filepath.Join(root, rel)) + if result[path] == nil { + result[path] = map[token.Pos]bool{} + } + // scanChokepoint parses each file with a fresh FileSet (base position 1). + result[path][token.Pos(pos.Offset+1)] = true + } + } + } + return result +} + +func TestRoleGrantObjects(t *testing.T) { + const base = "github.com/nself-org/plugins/free/ci/internal/" + for _, tc := range []struct { + name, pkg string + method, want bool + }{ + {"role type", "store", false, true}, + {"role method", "roles", true, true}, + {"lease method", "lease", true, false}, + {"store method", "store", true, false}, + {"unrelated type", "other", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + pkg := types.NewPackage(base+tc.pkg, tc.pkg) + var obj types.Object = types.NewTypeName(token.NoPos, pkg, "Grant", nil) + if tc.method { + recv := types.NewVar(token.NoPos, pkg, "s", types.Typ[types.Int]) + obj = types.NewFunc(token.NoPos, pkg, "Grant", types.NewSignatureType(recv, nil, nil, nil, nil, false)) + } + if got := isRoleGrant(obj); got != tc.want { + t.Fatalf("isRoleGrant = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/free/ci/internal/dispatch/chokepoint_test.go b/free/ci/internal/dispatch/chokepoint_test.go index 70d164349..34951fbd9 100644 --- a/free/ci/internal/dispatch/chokepoint_test.go +++ b/free/ci/internal/dispatch/chokepoint_test.go @@ -87,7 +87,7 @@ func ownerOf(decl ast.Decl) (string, *ast.Ident) { } // scanFile reports the uses in one parsed file that the allowlist does not cover. -func scanFile(f *ast.File, rel string, add func(string)) { +func scanFile(f *ast.File, rel string, grants map[token.Pos]bool, add func(string)) { dir := filepath.ToSlash(filepath.Dir(rel)) usesReflect := false for _, imp := range f.Imports { @@ -108,6 +108,9 @@ func scanFile(f *ast.File, rel string, add func(string)) { switch x := n.(type) { case *ast.SelectorExpr: name := x.Sel.Name + if grants[x.Sel.Pos()] { + return true + } switch { case reflective[name], name == "Method" && usesReflect: report(name) @@ -118,8 +121,8 @@ func scanFile(f *ast.File, rel string, add func(string)) { case banned[name] && !chokeFuncs[key] && !(name == "Grant" && roleGrantFiles[rel]): report(name) } - case *ast.Ident: - if banned[x.Name] && x != declName && !(x.Name == "Grant" && roleGrantFiles[rel]) && !(x.Name == "LocalAdmit" && localFiles[rel]) && (x.Name == "LocalAdmit" || !chokeFuncs[key]) { + case *ast.Ident: + if banned[x.Name] && !grants[x.Pos()] && x != declName && !(x.Name == "LocalAdmit" && localFiles[rel]) && (x.Name == "LocalAdmit" || !chokeFuncs[key]) { report(x.Name) } case *ast.BasicLit: @@ -136,6 +139,11 @@ func scanFile(f *ast.File, rel string, add func(string)) { // allowlist, and how many files it read. func scanChokepoint(t *testing.T, root string) (violations []string, files int) { t.Helper() + root, absErr := filepath.Abs(root) + if absErr != nil { + t.Fatal(absErr) + } + roleGrants := roleGrantPositions(t, root) seen := map[string]bool{} add := func(v string) { // a selector and its identifier are two nodes if !seen[v] { @@ -163,7 +171,7 @@ func scanChokepoint(t *testing.T, root string) (violations []string, files int) t.Fatalf("parse %s: %v", path, perr) } files++ - scanFile(f, filepath.ToSlash(rel), add) + scanFile(f, filepath.ToSlash(rel), roleGrants[filepath.Clean(path)], add) return nil }) if err != nil { From 5ea4189a02c579b828c52cb22aa203eb8edfeecd Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 08:01:00 -0400 Subject: [PATCH 04/10] test(ci): make production-marker fixture independent of host Docker --- free/ci/cmd/agent/prodguard_test.go | 6 ++++++ free/ci/internal/dispatch/chokepoint_roles_test.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/free/ci/cmd/agent/prodguard_test.go b/free/ci/cmd/agent/prodguard_test.go index d0ae18cde..4017d2fa0 100644 --- a/free/ci/cmd/agent/prodguard_test.go +++ b/free/ci/cmd/agent/prodguard_test.go @@ -69,6 +69,12 @@ func TestServeStdioExitsNonZeroWithE663OnAProdStackMarker(t *testing.T) { } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) dir := t.TempDir() + // The marker guard checks for docker on PATH before calling the injected runner. + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("ENV=prod\n"), 0o600); err != nil { t.Fatal(err) } diff --git a/free/ci/internal/dispatch/chokepoint_roles_test.go b/free/ci/internal/dispatch/chokepoint_roles_test.go index 3c19fcd0d..e92636ed9 100644 --- a/free/ci/internal/dispatch/chokepoint_roles_test.go +++ b/free/ci/internal/dispatch/chokepoint_roles_test.go @@ -42,7 +42,7 @@ func roleGrantPositions(t *testing.T, root string) map[string]map[token.Pos]bool return result } pkgs, err := packages.Load(&packages.Config{Dir: root, - Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports | packages.NeedDeps}, "./...") + Mode: packages.LoadSyntax}, "./...") if err != nil { t.Fatal(err) } From b3173a46ae501b7a8b7390af6954336f2ecbaf82 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 08:04:29 -0400 Subject: [PATCH 05/10] test(ci): canonicalize Node fixture home on macOS --- free/ci/internal/gates/node_prepare_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/free/ci/internal/gates/node_prepare_test.go b/free/ci/internal/gates/node_prepare_test.go index ba34db591..a63e56c3f 100644 --- a/free/ci/internal/gates/node_prepare_test.go +++ b/free/ci/internal/gates/node_prepare_test.go @@ -103,9 +103,15 @@ type result struct { func (h *nodeHost) run(id, workdir string) result { h.t.Helper() p := h.plan(id, workdir) + // macOS temp roots can include /var -> /private/var. The executor rejects + // symlinked workdirs, so use the real fixture home just as a configured CI home. + home, err := filepath.EvalSymlinks(h.t.TempDir()) + if err != nil { + h.t.Fatal(err) + } var out strings.Builder res := (&ciexec.Executor{Isolation: ciexec.BaselineIsolator{}}).Run(context.Background(), ciexec.JobSpec{AttemptID: "node-" + strings.ReplaceAll(id, ".", "-"), CoordinatorID: "coord", - Secrets: h.secrets, Job: model.Job{Kind: "quality", Workdir: workdir, Isolation: "process", Env: h.env, Secrets: h.refs}, Command: p.Command(), SourceDir: h.root, Home: realTempDir(h.t), + Secrets: h.secrets, Job: model.Job{Kind: "quality", Workdir: workdir, Isolation: "process", Env: h.env, Secrets: h.refs}, Command: p.Command(), SourceDir: h.root, Home: home, Output: func(b []byte) { out.Write(b) }}) r := result{exit: res.ExitCode, out: strings.TrimSpace(out.String())} if res.Err != nil { From 1c7d581cb402d680b2642d6dcbf5a09d35a4fa40 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 08:11:34 -0400 Subject: [PATCH 06/10] fix(ci): align process-start guards with current exec funnel --- free/ci/internal/nodes/provision/single_exec_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/free/ci/internal/nodes/provision/single_exec_test.go b/free/ci/internal/nodes/provision/single_exec_test.go index 5409cf145..1d90d7154 100644 --- a/free/ci/internal/nodes/provision/single_exec_test.go +++ b/free/ci/internal/nodes/provision/single_exec_test.go @@ -70,6 +70,7 @@ var dynamicProgram = map[string]bool{ "internal/exec/container.go:containerStopper": true, "internal/exec/runtime.go:runtimeQuery": true, "internal/exec/runtime.go:RuntimeRemove": true, + "internal/exec/sweep.go:removeStaleContainer": true, // The sandboxed-container probe (P7-TRUST-05): the runtime binary is checked against // runtimeBinary, the runtime name and image against fixed patterns; runsc is run only by its // absolute path with base name runsc, with the one argument --version. From b794108040732d780f5899f2811744c5a7964444 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 08:15:33 -0400 Subject: [PATCH 07/10] fix(ci): make cancellation test deterministic and resolve package lint --- free/ci/internal/dispatch/chokepoint_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/free/ci/internal/dispatch/chokepoint_test.go b/free/ci/internal/dispatch/chokepoint_test.go index 34951fbd9..643a1b112 100644 --- a/free/ci/internal/dispatch/chokepoint_test.go +++ b/free/ci/internal/dispatch/chokepoint_test.go @@ -121,8 +121,8 @@ func scanFile(f *ast.File, rel string, grants map[token.Pos]bool, add func(strin case banned[name] && !chokeFuncs[key] && !(name == "Grant" && roleGrantFiles[rel]): report(name) } - case *ast.Ident: - if banned[x.Name] && !grants[x.Pos()] && x != declName && !(x.Name == "LocalAdmit" && localFiles[rel]) && (x.Name == "LocalAdmit" || !chokeFuncs[key]) { + case *ast.Ident: + if banned[x.Name] && !grants[x.Pos()] && x != declName && (x.Name != "LocalAdmit" || !localFiles[rel]) && (x.Name == "LocalAdmit" || !chokeFuncs[key]) { report(x.Name) } case *ast.BasicLit: From 15679d5529c288d501e96b9fe163eeee6e214366 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 08:19:19 -0400 Subject: [PATCH 08/10] fix(ci): audit operator trust inside disposable hosted workflow VMs --- .github/workflows/ci-scenarios.yml | 11 +++++++---- .github/workflows/nself-ci-source.yml | 12 +++++++++--- free/ci/internal/protect/template_test.go | 8 ++++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci-scenarios.yml b/.github/workflows/ci-scenarios.yml index 63615c718..fc99bc45e 100644 --- a/.github/workflows/ci-scenarios.yml +++ b/.github/workflows/ci-scenarios.yml @@ -10,6 +10,9 @@ # Constraints: actions pinned by SHA; scenario 1 cuts the network for its own processes with `sudo -n` (nft on Linux, pf on # macOS) and removes the cut on every exit path. Runs on pull requests that touch these paths and on demand. +# The disposable GitHub-hosted VM isolates each scenario. The source engine +# audits the explicit operator trust override; the workflow has a read-only token +# and receives no repository secrets. name: ci-scenarios on: @@ -84,8 +87,8 @@ jobs: - name: Scenario 12, external evidence consumer run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/evidence" - .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-12-external-consumer --json > "$RUNNER_TEMP/evidence/scenario-12.json" || true # exit 1 is the partial selection; the job check below decides + mkdir -p "$RUNNER_TEMP/evidence" + .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-12-external-consumer --json > "$RUNNER_TEMP/evidence/scenario-12.json" || true # exit 1 is the partial selection; the job check below decides # A single-job run is a partial selection, so its overall result is never pass: read the job's own check. jq -r '"scenario-12: " + ([.data.checks[] | select(.id == "drill-scenario-12-external-consumer") | .result][0] // "no result")' "$RUNNER_TEMP/evidence/scenario-12.json" | tee -a "$GITHUB_STEP_SUMMARY" jq -e '[.data.checks[] | select(.id == "drill-scenario-12-external-consumer") | .result] == ["pass"]' "$RUNNER_TEMP/evidence/scenario-12.json" >/dev/null @@ -105,8 +108,8 @@ jobs: DRILL_CUT_ALLOW_SHARED_HOST: ${{ runner.os == 'Linux' && '1' || '' }} run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/evidence" - .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-01-local-only --json > "$RUNNER_TEMP/evidence/scenario-01.json" || true # exit 1 is the partial selection; the job check below decides + mkdir -p "$RUNNER_TEMP/evidence" + .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-01-local-only --json > "$RUNNER_TEMP/evidence/scenario-01.json" || true # exit 1 is the partial selection; the job check below decides # A single-job run is a partial selection, so its overall result is never pass: read the job's own check. jq -r '"scenario-01: " + ([.data.checks[] | select(.id == "drill-scenario-01-local-only") | .result][0] // "no result")' "$RUNNER_TEMP/evidence/scenario-01.json" | tee -a "$GITHUB_STEP_SUMMARY" jq -e '[.data.checks[] | select(.id == "drill-scenario-01-local-only") | .result] == ["pass"]' "$RUNNER_TEMP/evidence/scenario-01.json" >/dev/null diff --git a/.github/workflows/nself-ci-source.yml b/.github/workflows/nself-ci-source.yml index 737442263..014ce6ae9 100644 --- a/.github/workflows/nself-ci-source.yml +++ b/.github/workflows/nself-ci-source.yml @@ -78,9 +78,15 @@ jobs: # --trust owner. A fork head leaves this empty, stays untrusted and is refused, as intended. NSELF_CI_TRUST: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'owner' || '' }} run: | - set -o pipefail - mkdir -p "$RUNNER_TEMP/evidence" - status=0 + set -o pipefail + mkdir -p "$RUNNER_TEMP/evidence" + status=0 + args=(--no-status --json) + if [ -n "$NSELF_CI_TRUST" ]; then args+=(--trust "$NSELF_CI_TRUST"); fi + "$NSELF_CI_BIN" "${args[@]}" > "$RUNNER_TEMP/evidence/nself-ci-source.json" || status=$? + # This disposable GitHub-hosted VM is the outer isolation boundary. + # The checkout-built engine audits this explicit local-operator override; + # no repository secrets or writable token are available to its jobs. args=(--no-status --json) if [ -n "$NSELF_CI_TRUST" ]; then args+=(--trust "$NSELF_CI_TRUST"); fi "$NSELF_CI_BIN" "${args[@]}" > "$RUNNER_TEMP/evidence/nself-ci-source.json" || status=$? diff --git a/free/ci/internal/protect/template_test.go b/free/ci/internal/protect/template_test.go index 7005b3b99..9da6d3ac3 100644 --- a/free/ci/internal/protect/template_test.go +++ b/free/ci/internal/protect/template_test.go @@ -120,8 +120,12 @@ func TestMutationPatchesApplyShape(t *testing.T) { } // A checkout-built engine cannot infer operator provenance from a fetched PR. +<<<<<<< HEAD // Its override is scoped to disposable hosted runners, without secrets or writes, // and only a head in this very repository gets it: a fork head stays untrusted. +======= +// Its override is scoped to disposable hosted runners, without secrets or writes. +>>>>>>> 7ddde49 (fix(ci): audit operator trust inside disposable hosted workflow VMs) func TestHostedSourceWorkflowTrust(t *testing.T) { for _, tc := range []struct { file, job, command, runner string @@ -138,7 +142,7 @@ func TestHostedSourceWorkflowTrust(t *testing.T) { } if len(d.Perm) != 1 || d.Perm["contents"] != "read" || strings.Contains(strings.ReplaceAll(raw, " ", ""), "${{secrets.") || strings.Contains(raw, "self-hosted") { t.Fatal("operator override requires disposable hosted execution, no secrets and a read-only token") - } + } if !strings.Contains(raw, "NSELF_CI_TRUST: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'owner' || '' }}") { t.Fatal("the audited override must be set only for a head in this repository") } @@ -148,7 +152,7 @@ func TestHostedSourceWorkflowTrust(t *testing.T) { if !strings.HasPrefix(strings.TrimSpace(line), tc.command+" ") { continue } - calls++ + calls++ } if strings.Contains(step.Run, tc.command+" ") && !strings.Contains(step.Run, `--trust "$NSELF_CI_TRUST"`) { t.Fatalf("missing audited operator override: %s", step.Run) From 87d836df5271a92886859236ca6e3a8155364c29 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 20 Oct 2026 08:24:26 -0400 Subject: [PATCH 09/10] refactor(ci): finish diagnostic lint and split approval and hook sources --- free/ci/cmd/approve_pending.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/free/ci/cmd/approve_pending.go b/free/ci/cmd/approve_pending.go index aff24f345..5cd5a9545 100644 --- a/free/ci/cmd/approve_pending.go +++ b/free/ci/cmd/approve_pending.go @@ -1,5 +1,6 @@ package main +// List pending approvals after voiding stale approval bindings. import ( "context" "flag" @@ -11,6 +12,8 @@ import ( "github.com/nself-org/plugins/free/ci/internal/roles" "github.com/nself-org/plugins/free/ci/internal/store" "github.com/nself-org/plugins/free/ci/internal/trust/source" + "io" + "strings" ) type pendingOut struct { From fd48c8b00d6844ee8bfd9f197d2bac18fc4c67ba Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Tue, 20 Oct 2026 09:54:05 -0400 Subject: [PATCH 10/10] test(ci): cancel isolation wait after entering the wait --- .github/workflows/ci-scenarios.yml | 11 ++++------- .github/workflows/nself-ci-source.yml | 12 +++--------- free/ci/cmd/agent/prodguard_test.go | 2 +- free/ci/cmd/approve_pending.go | 2 -- free/ci/internal/nodes/provision/single_exec_test.go | 1 - free/ci/internal/protect/template_test.go | 12 ++++-------- free/ci/internal/run/isolation_wait_test.go | 4 +++- 7 files changed, 15 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci-scenarios.yml b/.github/workflows/ci-scenarios.yml index fc99bc45e..63615c718 100644 --- a/.github/workflows/ci-scenarios.yml +++ b/.github/workflows/ci-scenarios.yml @@ -10,9 +10,6 @@ # Constraints: actions pinned by SHA; scenario 1 cuts the network for its own processes with `sudo -n` (nft on Linux, pf on # macOS) and removes the cut on every exit path. Runs on pull requests that touch these paths and on demand. -# The disposable GitHub-hosted VM isolates each scenario. The source engine -# audits the explicit operator trust override; the workflow has a read-only token -# and receives no repository secrets. name: ci-scenarios on: @@ -87,8 +84,8 @@ jobs: - name: Scenario 12, external evidence consumer run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/evidence" - .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-12-external-consumer --json > "$RUNNER_TEMP/evidence/scenario-12.json" || true # exit 1 is the partial selection; the job check below decides + mkdir -p "$RUNNER_TEMP/evidence" + .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-12-external-consumer --json > "$RUNNER_TEMP/evidence/scenario-12.json" || true # exit 1 is the partial selection; the job check below decides # A single-job run is a partial selection, so its overall result is never pass: read the job's own check. jq -r '"scenario-12: " + ([.data.checks[] | select(.id == "drill-scenario-12-external-consumer") | .result][0] // "no result")' "$RUNNER_TEMP/evidence/scenario-12.json" | tee -a "$GITHUB_STEP_SUMMARY" jq -e '[.data.checks[] | select(.id == "drill-scenario-12-external-consumer") | .result] == ["pass"]' "$RUNNER_TEMP/evidence/scenario-12.json" >/dev/null @@ -108,8 +105,8 @@ jobs: DRILL_CUT_ALLOW_SHARED_HOST: ${{ runner.os == 'Linux' && '1' || '' }} run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/evidence" - .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-01-local-only --json > "$RUNNER_TEMP/evidence/scenario-01.json" || true # exit 1 is the partial selection; the job check below decides + mkdir -p "$RUNNER_TEMP/evidence" + .drill-bin/nself-ci --no-status ${NSELF_CI_TRUST:+--trust "$NSELF_CI_TRUST"} --pipeline drills --job drill-scenario-01-local-only --json > "$RUNNER_TEMP/evidence/scenario-01.json" || true # exit 1 is the partial selection; the job check below decides # A single-job run is a partial selection, so its overall result is never pass: read the job's own check. jq -r '"scenario-01: " + ([.data.checks[] | select(.id == "drill-scenario-01-local-only") | .result][0] // "no result")' "$RUNNER_TEMP/evidence/scenario-01.json" | tee -a "$GITHUB_STEP_SUMMARY" jq -e '[.data.checks[] | select(.id == "drill-scenario-01-local-only") | .result] == ["pass"]' "$RUNNER_TEMP/evidence/scenario-01.json" >/dev/null diff --git a/.github/workflows/nself-ci-source.yml b/.github/workflows/nself-ci-source.yml index 014ce6ae9..737442263 100644 --- a/.github/workflows/nself-ci-source.yml +++ b/.github/workflows/nself-ci-source.yml @@ -78,15 +78,9 @@ jobs: # --trust owner. A fork head leaves this empty, stays untrusted and is refused, as intended. NSELF_CI_TRUST: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'owner' || '' }} run: | - set -o pipefail - mkdir -p "$RUNNER_TEMP/evidence" - status=0 - args=(--no-status --json) - if [ -n "$NSELF_CI_TRUST" ]; then args+=(--trust "$NSELF_CI_TRUST"); fi - "$NSELF_CI_BIN" "${args[@]}" > "$RUNNER_TEMP/evidence/nself-ci-source.json" || status=$? - # This disposable GitHub-hosted VM is the outer isolation boundary. - # The checkout-built engine audits this explicit local-operator override; - # no repository secrets or writable token are available to its jobs. + set -o pipefail + mkdir -p "$RUNNER_TEMP/evidence" + status=0 args=(--no-status --json) if [ -n "$NSELF_CI_TRUST" ]; then args+=(--trust "$NSELF_CI_TRUST"); fi "$NSELF_CI_BIN" "${args[@]}" > "$RUNNER_TEMP/evidence/nself-ci-source.json" || status=$? diff --git a/free/ci/cmd/agent/prodguard_test.go b/free/ci/cmd/agent/prodguard_test.go index 4017d2fa0..df8732752 100644 --- a/free/ci/cmd/agent/prodguard_test.go +++ b/free/ci/cmd/agent/prodguard_test.go @@ -70,7 +70,7 @@ func TestServeStdioExitsNonZeroWithE663OnAProdStackMarker(t *testing.T) { t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) dir := t.TempDir() // The marker guard checks for docker on PATH before calling the injected runner. - bin := t.TempDir() + bin = t.TempDir() if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { t.Fatal(err) } diff --git a/free/ci/cmd/approve_pending.go b/free/ci/cmd/approve_pending.go index 5cd5a9545..372b227b5 100644 --- a/free/ci/cmd/approve_pending.go +++ b/free/ci/cmd/approve_pending.go @@ -12,8 +12,6 @@ import ( "github.com/nself-org/plugins/free/ci/internal/roles" "github.com/nself-org/plugins/free/ci/internal/store" "github.com/nself-org/plugins/free/ci/internal/trust/source" - "io" - "strings" ) type pendingOut struct { diff --git a/free/ci/internal/nodes/provision/single_exec_test.go b/free/ci/internal/nodes/provision/single_exec_test.go index 1d90d7154..5409cf145 100644 --- a/free/ci/internal/nodes/provision/single_exec_test.go +++ b/free/ci/internal/nodes/provision/single_exec_test.go @@ -70,7 +70,6 @@ var dynamicProgram = map[string]bool{ "internal/exec/container.go:containerStopper": true, "internal/exec/runtime.go:runtimeQuery": true, "internal/exec/runtime.go:RuntimeRemove": true, - "internal/exec/sweep.go:removeStaleContainer": true, // The sandboxed-container probe (P7-TRUST-05): the runtime binary is checked against // runtimeBinary, the runtime name and image against fixed patterns; runsc is run only by its // absolute path with base name runsc, with the one argument --version. diff --git a/free/ci/internal/protect/template_test.go b/free/ci/internal/protect/template_test.go index 9da6d3ac3..131f6ad07 100644 --- a/free/ci/internal/protect/template_test.go +++ b/free/ci/internal/protect/template_test.go @@ -120,20 +120,16 @@ func TestMutationPatchesApplyShape(t *testing.T) { } // A checkout-built engine cannot infer operator provenance from a fetched PR. -<<<<<<< HEAD // Its override is scoped to disposable hosted runners, without secrets or writes, // and only a head in this very repository gets it: a fork head stays untrusted. -======= -// Its override is scoped to disposable hosted runners, without secrets or writes. ->>>>>>> 7ddde49 (fix(ci): audit operator trust inside disposable hosted workflow VMs) func TestHostedSourceWorkflowTrust(t *testing.T) { for _, tc := range []struct { file, job, command, runner string calls int }{ - {"../../.github/workflows/nself-ci-source.yml", "nself-ci-source", `"$NSELF_CI_BIN"`, "ubuntu-latest", 1}, +{"../../.github/workflows/nself-ci-source.yml", "nself-ci-source", `"$NSELF_CI_BIN"`, "ubuntu-latest", 1}, {"../../.github/workflows/ci-scenarios.yml", "scenarios", ".drill-bin/nself-ci", "${{ matrix.os }}", 2}, - } { +} { t.Run(tc.job, func(t *testing.T) { d, raw := readWorkflow(t, tc.file) job, ok := d.Jobs[tc.job] @@ -142,7 +138,7 @@ func TestHostedSourceWorkflowTrust(t *testing.T) { } if len(d.Perm) != 1 || d.Perm["contents"] != "read" || strings.Contains(strings.ReplaceAll(raw, " ", ""), "${{secrets.") || strings.Contains(raw, "self-hosted") { t.Fatal("operator override requires disposable hosted execution, no secrets and a read-only token") - } + } if !strings.Contains(raw, "NSELF_CI_TRUST: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'owner' || '' }}") { t.Fatal("the audited override must be set only for a head in this repository") } @@ -152,7 +148,7 @@ func TestHostedSourceWorkflowTrust(t *testing.T) { if !strings.HasPrefix(strings.TrimSpace(line), tc.command+" ") { continue } - calls++ + calls++ } if strings.Contains(step.Run, tc.command+" ") && !strings.Contains(step.Run, `--trust "$NSELF_CI_TRUST"`) { t.Fatalf("missing audited operator override: %s", step.Run) diff --git a/free/ci/internal/run/isolation_wait_test.go b/free/ci/internal/run/isolation_wait_test.go index 9980fec1b..f88bf70c1 100644 --- a/free/ci/internal/run/isolation_wait_test.go +++ b/free/ci/internal/run/isolation_wait_test.go @@ -118,7 +118,9 @@ func TestRunWaitingStopsOnCancel(t *testing.T) { rs, spec := waitRun(t, iso) rs.sess.slots.take(slotDir(rs.sess.Home)) // slot 0 is held elsewhere, so the attempt waits on slot 1 ctx, cancel := context.WithCancel(context.Background()) - time.AfterFunc(50*time.Millisecond, cancel) + defer cancel() + // Cancel once the attempt enters its wait, independently of executor setup time. + spec.Output = func([]byte) { cancel() } start := time.Now() res := rs.runWaiting(ctx, spec) if string(res.Attempt.State) != "cancelled" || time.Since(start) > 5*time.Second {