From 6dfe7d102811d7dfb812a37562533caec2379797 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Mon, 7 Sep 2026 18:39:42 +0500 Subject: [PATCH] fix: isolate module verification and refresh source provenance Signed-off-by: rldyourmnd --- .gds/bundle.lock.yaml | 10 +++---- .github/workflows/gds-ci.yml | 4 +-- core/app/module_command_output.go | 34 ++++++++++++++++++++++++ core/app/module_verify.go | 27 ++++++++++++++++--- core/app/module_verify_test.go | 43 +++++++++++++++++++++++++++++++ docs/contracts/lifecycles-v1.md | 5 ++++ 6 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 core/app/module_command_output.go diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 1b719b4..8601472 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.8.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:ca04e2e010b59a80218836f47fe9838f06a78b518f55c903f1299ce185c76247" - digest: "sha256:3834cde36107650e45f5614d4221d6a9eace7a063933438ca6a1d781f3dce3fa" + source_tree_digest: "sha256:6a2841ed1461b416ed9e3c393f5e7bd09cad1910ca4a99ed141aa55961860b0f" + digest: "sha256:d238ecef552753c9e73f67daf6efc4b7784198e6115463b1745429d43c1b1930" projection: - input_digest: "sha256:443879da16dbc62efbe4fcb917d17542f2d76cedd899e745ff7b9bbcf4bb2659" - output_digest: "sha256:5d3da922fd206d208571be474d78ad1e1796700c3e1c9ed7e19058806203a65a" + input_digest: "sha256:7f1cedba841019882c248c7a6fbd70894d86745bba56853f9fcd298994253f06" + output_digest: "sha256:6957de5ca56f78ea1b18802a04e3ef25f53558025ef64292f4329bff8731601c" files: - path: ".gds/compiled-policy.json" digest: "sha256:807282f820294914e1c7e6ad1bf27c54a799d56305c58630254ab50ab286f379" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:43589121d2564b8cf14c270a143fc2b910bb016edab2a72a77a5ec981d749965" + digest: "sha256:9356f7db1950f27c280da32e72588d78f3fcbfa8224cdb2690d752bc53b61c0c" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index b859b09..18a8f59 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.8.0-dev -# source-tree-digest: sha256:ca04e2e010b59a80218836f47fe9838f06a78b518f55c903f1299ce185c76247 -# input-digest: sha256:443879da16dbc62efbe4fcb917d17542f2d76cedd899e745ff7b9bbcf4bb2659 +# source-tree-digest: sha256:6a2841ed1461b416ed9e3c393f5e7bd09cad1910ca4a99ed141aa55961860b0f +# input-digest: sha256:7f1cedba841019882c248c7a6fbd70894d86745bba56853f9fcd298994253f06 # output-digest: sha256:a911ec3d1d5728bbe37fec78e04cc8452b9dbb1e8b394d0b8cace067804eff9a # edit-source: # - .gds/repository.yaml diff --git a/core/app/module_command_output.go b/core/app/module_command_output.go new file mode 100644 index 0000000..85d5bf6 --- /dev/null +++ b/core/app/module_command_output.go @@ -0,0 +1,34 @@ +package app + +import "sync" + +// Keep a bounded tail while the process runs, rather than accumulating an +// arbitrary build log and truncating only after the command has exited. +type moduleCommandOutput struct { + mu sync.Mutex + tail []byte +} + +const moduleCommandOutputLimit = 16 << 10 + +func (output *moduleCommandOutput) Write(value []byte) (int, error) { + output.mu.Lock() + defer output.mu.Unlock() + written := len(value) + if len(value) >= moduleCommandOutputLimit { + output.tail = append(output.tail[:0], value[len(value)-moduleCommandOutputLimit:]...) + return written, nil + } + if discard := len(output.tail) + len(value) - moduleCommandOutputLimit; discard > 0 { + copy(output.tail, output.tail[discard:]) + output.tail = output.tail[:len(output.tail)-discard] + } + output.tail = append(output.tail, value...) + return written, nil +} + +func (output *moduleCommandOutput) String() string { + output.mu.Lock() + defer output.mu.Unlock() + return string(output.tail) +} diff --git a/core/app/module_verify.go b/core/app/module_verify.go index 48e9a1e..3f7be24 100644 --- a/core/app/module_verify.go +++ b/core/app/module_verify.go @@ -44,7 +44,7 @@ type CommandReport struct { Status string `json:"status"` ExitCode int `json:"exit_code"` DurationMS int64 `json:"duration_ms"` - // Diagnostic is the tail of stderr, bounded and redacted. It is the whole + // Diagnostic is the combined output tail, bounded and redacted. It is the whole // difference between a usable report and a guess: a failing command may be a // defect in the module or a tool missing from this device, and nothing in // the exit code separates those. `python3 -m pytest` exiting 1 in 14ms with @@ -275,7 +275,20 @@ func runDeclaredCommand( command := exec.CommandContext(bounded, "bash", "-euo", "pipefail", "-c", declared) command.Dir = directory command.Stdin = nil - diagnostic := &strings.Builder{} + // This selector belongs to the controller operation. Module commands prove + // their own source checkout, and must not silently select its consumer's + // estate. A declared command can still explicitly select an estate itself. + environment := os.Environ() + command.Env = make([]string, 0, len(environment)) + for _, value := range environment { + if !strings.HasPrefix(value, "GDS_ESTATE_ROOT=") { + command.Env = append(command.Env, value) + } + } + // Bound inherited output pipes as well as the command itself. + command.WaitDelay = 2 * time.Second + diagnostic := &moduleCommandOutput{} + command.Stdout = diagnostic command.Stderr = diagnostic err := command.Run() report := CommandReport{ @@ -286,17 +299,23 @@ func runDeclaredCommand( return report } report.Diagnostic = boundedDiagnostic(diagnostic.String()) + if report.Diagnostic == "" { + report.Diagnostic = boundedDiagnostic(err.Error()) + } if errors.Is(bounded.Err(), context.DeadlineExceeded) { report.Status = "timeout" report.ExitCode = -1 return report } report.Status = "failed" - report.ExitCode = command.ProcessState.ExitCode() + report.ExitCode = -1 + if command.ProcessState != nil { + report.ExitCode = command.ProcessState.ExitCode() + } return report } -// boundedDiagnostic keeps the last lines of stderr, redacted and bounded. +// boundedDiagnostic keeps the last output lines, redacted and bounded. // // The tail rather than the head: a failing build prints its progress first and // its reason last, so the head is the part nobody needs. The bound exists diff --git a/core/app/module_verify_test.go b/core/app/module_verify_test.go index 0bb09b7..61c22da 100644 --- a/core/app/module_verify_test.go +++ b/core/app/module_verify_test.go @@ -74,3 +74,46 @@ func TestBoundedDiagnosticKeepsTheTail(t *testing.T) { t.Fatalf("diagnostic = %q", bounded[:40]) } } + +func TestDeclaredCommandDoesNotInheritControllerEstateSelection(t *testing.T) { + t.Setenv("GDS_ESTATE_ROOT", t.TempDir()) + t.Setenv("GDS_MODULE_TEST_VALUE", "preserved") + report := runDeclaredCommand(context.Background(), t.TempDir(), + `test -z "${GDS_ESTATE_ROOT+x}" && test "$GDS_MODULE_TEST_VALUE" = preserved`, 30*time.Second) + if report.Status != "passed" { + t.Fatalf("controller selection leaked into module: %#v", report) + } +} + +func TestDeclaredCommandPreservesStdoutFailure(t *testing.T) { + t.Parallel() + report := runDeclaredCommand(context.Background(), t.TempDir(), `printf 'FAIL: module assertion\n'; exit 1`, 30*time.Second) + if report.Status != "failed" || !strings.Contains(report.Diagnostic, "FAIL: module assertion") { + t.Fatalf("stdout failure lost: %#v", report) + } +} + +func TestModuleCommandOutputBoundsBothStreamsDuringExecution(t *testing.T) { + t.Parallel() + output := &moduleCommandOutput{} + for _, value := range []string{strings.Repeat("first", 20000), strings.Repeat("second", 20000), "last diagnostic"} { + n, err := output.Write([]byte(value)) + if err != nil || n != len(value) { + t.Fatalf("write=%d %v", n, err) + } + if len(output.String()) > moduleCommandOutputLimit { + t.Fatal("unbounded process output") + } + } + if !strings.HasSuffix(output.String(), "last diagnostic") { + t.Fatal("tail lost") + } +} + +func TestDeclaredCommandReportsUnavailableShellWithoutPanic(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + report := runDeclaredCommand(context.Background(), t.TempDir(), "true", time.Second) + if report.Status != "failed" || report.ExitCode != -1 || report.Diagnostic == "" { + t.Fatalf("launch failure=%#v", report) + } +} diff --git a/docs/contracts/lifecycles-v1.md b/docs/contracts/lifecycles-v1.md index 336c1f7..334668f 100644 --- a/docs/contracts/lifecycles-v1.md +++ b/docs/contracts/lifecycles-v1.md @@ -180,6 +180,11 @@ sends the reader looking for a concurrent writer that does not exist. Any command whose deadline expires now also carries `GDS_COMMAND_DEADLINE_EXCEEDED`, naming the deadline and the flag. +Module commands run without the controller's ambient `GDS_ESTATE_ROOT` selector; +their source checks must resolve their own checkout. A declared command may +explicitly select an estate when its contract requires one. Failures retain a +bounded, redacted tail from both stdout and stderr, including launch errors. + Applying a pin needs no approval. The only mutation is a gitlink rewrite in the consumer's own working tree: it writes no provider, replaces no credential and publishes nothing, and the consumer's pull request and checks are its real gate.