diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c83e1d..992d690 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,7 +89,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: "22" cache: "npm" @@ -122,7 +122,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: "22" cache: "npm" @@ -186,12 +186,13 @@ jobs: shell: bash run: | set +e - go test ./... -cover > coverage-go-packages.out 2>&1 + mapfile -t go_test_packages < <(python scripts/list_go_test_packages.py) + go test "${go_test_packages[@]}" -cover > coverage-go-packages.out 2>&1 package_test_status=$? set -e cat coverage-go-packages.out if [[ "${package_test_status}" -ne 0 ]]; then - echo "go test ./... -cover failed" + echo "go test filtered packages with coverage failed" exit "${package_test_status}" fi python scripts/check_go_package_coverage.py coverage-go-packages.out 75 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 89c7294..28a7daf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: '22' cache: 'npm' diff --git a/.github/workflows/nightly-full-windows.yml b/.github/workflows/nightly-full-windows.yml index 075aa74..32b5a36 100644 --- a/.github/workflows/nightly-full-windows.yml +++ b/.github/workflows/nightly-full-windows.yml @@ -49,12 +49,13 @@ jobs: shell: bash run: | set +e - go test ./... -cover > coverage-go-packages.out 2>&1 + mapfile -t go_test_packages < <(python scripts/list_go_test_packages.py) + go test "${go_test_packages[@]}" -cover > coverage-go-packages.out 2>&1 package_test_status=$? set -e cat coverage-go-packages.out if [[ "${package_test_status}" -ne 0 ]]; then - echo "go test ./... -cover failed" + echo "go test filtered packages with coverage failed" exit "${package_test_status}" fi python scripts/check_go_package_coverage.py coverage-go-packages.out 75 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e9cec9..7a2011f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,7 @@ Keep monitored GitHub Actions references on the repo's Node 24-safe baseline: - `actions/checkout@v5` or newer - `actions/setup-go@v6` or newer - `actions/setup-python@v6` or newer +- `actions/setup-node@v5` or newer - `github/codeql-action/init@v4` and `github/codeql-action/analyze@v4` or newer Validate that baseline locally before opening a PR: @@ -64,6 +65,7 @@ python3 scripts/check_github_action_runtime_versions.py .github/workflows docs/a ``` `make lint-fast` and `make lint` run the same guard through `scripts/check_repo_hygiene.sh`, so runtime deprecations fail before longer suites. +The guard covers action runtime majors, not the application Node version configured in a workflow, so docs/UI jobs may keep `node-version: "22"` while still requiring `actions/setup-node@v5+`. Do not rely on `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` or `ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true` as the default fix path; keep those as emergency-only compatibility levers while the action pin is being updated. ## Branch protection and required checks diff --git a/Makefile b/Makefile index 1d83006..11c255d 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ endif SDK_DIR := sdk/python UV_PY := 3.11 +GO_TEST_PACKAGES := $(shell GO=$(GO) $(PYTHON) scripts/list_go_test_packages.py) GO_COVERAGE_PACKAGES := ./core/... ./cmd/gait BENCH_PACKAGES := ./core/gate ./core/runpack ./core/scout ./core/guard ./core/registry ./core/mcp BENCH_REGEX := Benchmark(EvaluatePolicyTypical|VerifyZipTypical|DiffRunpacksTypical|SnapshotTypical|DiffSnapshotsTypical|VerifyPackTypical|BuildIncidentPackTypical|InstallLocalTypical|VerifyInstalledTypical|DecodeToolCallOpenAITypical|EvaluateToolCallTypical)$$ @@ -55,7 +56,7 @@ codeql: bash scripts/run_codeql_local.sh test: - $(GO) test ./... -cover > coverage-go-packages.out + $(GO) test $(GO_TEST_PACKAGES) -cover > coverage-go-packages.out cat coverage-go-packages.out $(PYTHON) scripts/check_go_package_coverage.py coverage-go-packages.out $(GO_PACKAGE_COVERAGE_THRESHOLD) $(GO) test $(GO_COVERAGE_PACKAGES) -coverprofile=coverage-go.out @@ -64,7 +65,7 @@ test: bash scripts/test_github_action_runtime_versions.sh test-fast: - $(GO) test ./... + $(GO) test $(GO_TEST_PACKAGES) (cd $(SDK_DIR) && PYTHONPATH=. uv run --python $(UV_PY) --extra dev pytest) bash scripts/test_github_action_runtime_versions.sh diff --git a/README.md b/README.md index 24efbae..975b392 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Gait sits at the execution boundary between an agent decision and a real tool ca Offline-first. Fail-closed. Portable evidence. +Fast proof is not the same as hardened `oss-prod` readiness. Strict inline fail-closed enforcement starts only when you control the real tool-execution seam. + Docs: [clyra-ai.github.io/gait](https://clyra-ai.github.io/gait/) | Install: [`docs/install.md`](docs/install.md) | Examples: [`examples/integrations/`](examples/integrations/) | Command docs: [`docs/README.md`](docs/README.md) ## In Brief @@ -60,6 +62,8 @@ This path gives you: - one signed demo artifact you can verify offline - one deterministic regress gate you can drop into CI immediately +This is proof of install plus artifact contract. It does not put Gait in front of a live tool boundary yet. + ### Add Repo Policy To A Real Project Use this when you want a repo-root policy file and a local contract check. @@ -71,10 +75,14 @@ gait check --json This path writes `.gait.yaml`, reports the live policy contract, and returns install-safe next commands. +It still does not enforce inline unless you wire Gait into a real wrapper, sidecar, middleware, or MCP boundary before the tool call executes. + ### Integrate At The Runtime Boundary Use this when your agent already makes real tool calls and you want enforcement at the execution seam. +This is the first path that enables strict inline fail-closed enforcement. If you cannot intercept tool execution before side effects, stay on the proof, observe, capture, and regress paths instead of claiming runtime blocking. + Official and reference lanes: - LangChain middleware lane (official): [`examples/integrations/langchain/`](examples/integrations/langchain/) @@ -87,6 +95,24 @@ Other supported boundary paths: No account. No API key. No hosted dependency. +### Harden `oss-prod` Readiness Explicitly + +Use this only after the fast proof and a real runtime boundary are already in place. + +```bash +gait init --json +# From a repo checkout: +cp examples/config/oss_prod_template.yaml .gait/config.yaml + +# Or, after a binary-only install: +curl -fsSL https://raw.githubusercontent.com/Clyra-AI/gait/main/examples/config/oss_prod_template.yaml -o .gait/config.yaml + +gait check --json +gait doctor --production-readiness --json +``` + +Do not describe a deployment as hardened `oss-prod` until that readiness check returns `ok=true`. + ## Why Gait Agent frameworks decide what to do. Gait decides whether the tool action may execute. diff --git a/cmd/gait/doctor.go b/cmd/gait/doctor.go index e7420e1..be5e9a6 100644 --- a/cmd/gait/doctor.go +++ b/cmd/gait/doctor.go @@ -4,6 +4,9 @@ import ( "flag" "fmt" "io" + "os" + "os/exec" + "path/filepath" "strings" "time" @@ -13,19 +16,23 @@ import ( ) type doctorOutput struct { - OK bool `json:"ok"` - SummaryMode bool `json:"summary_mode,omitempty"` - SchemaID string `json:"schema_id,omitempty"` - SchemaVersion string `json:"schema_version,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - ProducerVersion string `json:"producer_version,omitempty"` - OnboardingMode string `json:"onboarding_mode,omitempty"` - Status string `json:"status,omitempty"` - NonFixable bool `json:"non_fixable,omitempty"` - Summary string `json:"summary,omitempty"` - FixCommands []string `json:"fix_commands,omitempty"` - Checks []doctor.Check `json:"checks,omitempty"` - Error string `json:"error,omitempty"` + OK bool `json:"ok"` + SummaryMode bool `json:"summary_mode,omitempty"` + SchemaID string `json:"schema_id,omitempty"` + SchemaVersion string `json:"schema_version,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + ProducerVersion string `json:"producer_version,omitempty"` + OnboardingMode string `json:"onboarding_mode,omitempty"` + Status string `json:"status,omitempty"` + NonFixable bool `json:"non_fixable,omitempty"` + Summary string `json:"summary,omitempty"` + FixCommands []string `json:"fix_commands,omitempty"` + BinaryPath string `json:"binary_path,omitempty"` + BinaryPathSource string `json:"binary_path_source,omitempty"` + PathBinaryPath string `json:"path_binary_path,omitempty"` + BinaryVersion string `json:"binary_version,omitempty"` + Checks []doctor.Check `json:"checks,omitempty"` + Error string `json:"error,omitempty"` } type doctorAdoptionOutput struct { @@ -34,6 +41,8 @@ type doctorAdoptionOutput struct { Error string `json:"error,omitempty"` } +var resolveDoctorInvokedBinaryPath = currentDoctorInvokedBinaryPath + func runDoctor(arguments []string) int { if len(arguments) > 0 && strings.TrimSpace(arguments[0]) == "adoption" { return runDoctorAdoption(arguments[1:]) @@ -91,6 +100,7 @@ func runDoctor(arguments []string) int { PublicKeyEnv: publicKeyEnv, }, ProductionReadiness: productionReadiness, + InvokedBinaryPath: resolveDoctorInvokedBinaryPath(), }) exitCode := exitOK @@ -103,18 +113,22 @@ func runDoctor(arguments []string) int { ok = false } return writeDoctorOutput(jsonOutput, doctorOutput{ - OK: ok, - SummaryMode: summaryMode, - SchemaID: result.SchemaID, - SchemaVersion: result.SchemaVersion, - CreatedAt: result.CreatedAt, - ProducerVersion: result.ProducerVersion, - OnboardingMode: result.OnboardingMode, - Status: result.Status, - NonFixable: result.NonFixable, - Summary: result.Summary, - FixCommands: result.FixCommands, - Checks: result.Checks, + OK: ok, + SummaryMode: summaryMode, + SchemaID: result.SchemaID, + SchemaVersion: result.SchemaVersion, + CreatedAt: result.CreatedAt, + ProducerVersion: result.ProducerVersion, + OnboardingMode: result.OnboardingMode, + Status: result.Status, + NonFixable: result.NonFixable, + Summary: result.Summary, + FixCommands: result.FixCommands, + BinaryPath: result.BinaryPath, + BinaryPathSource: result.BinaryPathSource, + PathBinaryPath: result.PathBinaryPath, + BinaryVersion: result.BinaryVersion, + Checks: result.Checks, }, exitCode) } @@ -224,3 +238,44 @@ func printDoctorAdoptionUsage() { fmt.Println("Usage:") fmt.Println(" gait doctor adoption --from [--json] [--explain]") } + +func currentDoctorInvokedBinaryPath() string { + if path, ok := resolveDoctorExecutablePath(os.Args[0]); ok { + return path + } + executablePath, err := os.Executable() + if err != nil { + return "" + } + path, ok := resolveDoctorExecutablePath(executablePath) + if !ok { + return "" + } + return path +} + +func resolveDoctorExecutablePath(rawPath string) (string, bool) { + trimmed := strings.TrimSpace(rawPath) + if trimmed == "" { + return "", false + } + base := strings.ToLower(filepath.Base(trimmed)) + if base != "gait" && base != "gait.exe" { + return "", false + } + if filepath.IsAbs(trimmed) { + return filepath.Clean(trimmed), true + } + if strings.ContainsRune(trimmed, filepath.Separator) { + absolute, err := filepath.Abs(trimmed) + if err != nil { + return filepath.Clean(trimmed), true + } + return filepath.Clean(absolute), true + } + resolved, err := exec.LookPath(trimmed) + if err != nil { + return filepath.Clean(trimmed), true + } + return filepath.Clean(resolved), true +} diff --git a/cmd/gait/main_test.go b/cmd/gait/main_test.go index b0aa005..f1feba5 100644 --- a/cmd/gait/main_test.go +++ b/cmd/gait/main_test.go @@ -2897,6 +2897,75 @@ func TestDoctorProductionReadinessIgnoresRepoOnlyChecks(t *testing.T) { } } +func TestRunDoctorJSONReportsInvokedBinaryPathWhenPATHDiffers(t *testing.T) { + workDir := t.TempDir() + withWorkingDir(t, workDir) + + outputDir := filepath.Join(workDir, "gait-out") + if err := os.MkdirAll(outputDir, 0o750); err != nil { + t.Fatalf("mkdir output dir: %v", err) + } + + pathDir := t.TempDir() + pathBinary := writeFakeDoctorBinary(t, pathDir, "gait", "gait 0.0.0-path") + invokedBinary := writeFakeDoctorBinary(t, t.TempDir(), "gait", "gait 0.0.0-invoked") + t.Setenv("PATH", pathDir) + + originalResolve := resolveDoctorInvokedBinaryPath + resolveDoctorInvokedBinaryPath = func() string { + return invokedBinary + } + t.Cleanup(func() { + resolveDoctorInvokedBinaryPath = originalResolve + }) + + var code int + raw := captureStdout(t, func() { + code = runDoctor([]string{ + "--workdir", workDir, + "--output-dir", outputDir, + "--json", + }) + }) + if code != exitOK { + t.Fatalf("runDoctor expected %d got %d", exitOK, code) + } + + var output doctorOutput + if err := json.Unmarshal([]byte(raw), &output); err != nil { + t.Fatalf("decode doctor output: %v (%s)", err, raw) + } + if output.BinaryPath != invokedBinary { + t.Fatalf("expected invoked binary path %q, got %q", invokedBinary, output.BinaryPath) + } + if output.BinaryPathSource != "invoked_executable" { + t.Fatalf("expected invoked_executable source, got %q", output.BinaryPathSource) + } + if output.PathBinaryPath != pathBinary { + t.Fatalf("expected PATH binary path %q, got %q", pathBinary, output.PathBinaryPath) + } + if output.BinaryVersion != "gait 0.0.0-invoked" { + t.Fatalf("expected invoked binary version, got %q", output.BinaryVersion) + } + + var onboardingCheck *doctor.Check + for index := range output.Checks { + if output.Checks[index].Name == "onboarding_binary" { + onboardingCheck = &output.Checks[index] + break + } + } + if onboardingCheck == nil { + t.Fatalf("expected onboarding_binary check in output") + } + if !strings.Contains(onboardingCheck.Message, "invoked_path="+invokedBinary) { + t.Fatalf("expected invoked path in onboarding message, got %q", onboardingCheck.Message) + } + if !strings.Contains(onboardingCheck.Message, "path_binary="+pathBinary) { + t.Fatalf("expected PATH collision in onboarding message, got %q", onboardingCheck.Message) + } +} + func assertNoRepoExampleNextCommands(t *testing.T, nextCommands []string) { t.Helper() for _, command := range nextCommands { @@ -4092,6 +4161,24 @@ func mustWriteFile(t *testing.T, path, content string) { } } +func writeFakeDoctorBinary(t *testing.T, dir, baseName, version string) string { + t.Helper() + + binaryPath := filepath.Join(dir, baseName) + content := "#!/bin/sh\nif [ \"$1\" = \"version\" ]; then\n echo \"" + version + "\"\n exit 0\nfi\necho \"" + version + "\"\n" + mode := os.FileMode(0o755) + if runtime.GOOS == "windows" { + baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName)) + binaryPath = filepath.Join(dir, baseName+".cmd") + content = "@echo off\r\nif \"%1\"==\"version\" (\r\n echo " + version + "\r\n exit /b 0\r\n)\r\necho " + version + "\r\n" + mode = 0o600 + } + if err := os.WriteFile(binaryPath, []byte(content), mode); err != nil { + t.Fatalf("write fake doctor binary: %v", err) + } + return binaryPath +} + func repoRootFromPackageDir(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) diff --git a/core/doctor/doctor.go b/core/doctor/doctor.go index cd8bbf4..8ca344d 100644 --- a/core/doctor/doctor.go +++ b/core/doctor/doctor.go @@ -27,6 +27,10 @@ const ( onboardingModeInstalledBinary = "installed_binary" onboardingModeRepoCheckout = "repo_checkout" + + binaryPathSourceInvokedExecutable = "invoked_executable" + binaryPathSourcePath = "path" + binaryPathSourceWorkDir = "workdir" ) type Options struct { @@ -36,19 +40,24 @@ type Options struct { KeyMode sign.KeyMode KeyConfig sign.KeyConfig ProductionReadiness bool + InvokedBinaryPath string } type Result struct { - SchemaID string `json:"schema_id"` - SchemaVersion string `json:"schema_version"` - CreatedAt string `json:"created_at"` - ProducerVersion string `json:"producer_version"` - OnboardingMode string `json:"onboarding_mode,omitempty"` - Status string `json:"status"` - NonFixable bool `json:"non_fixable"` - Summary string `json:"summary"` - FixCommands []string `json:"fix_commands"` - Checks []Check `json:"checks"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + CreatedAt string `json:"created_at"` + ProducerVersion string `json:"producer_version"` + OnboardingMode string `json:"onboarding_mode,omitempty"` + Status string `json:"status"` + NonFixable bool `json:"non_fixable"` + Summary string `json:"summary"` + FixCommands []string `json:"fix_commands"` + BinaryPath string `json:"binary_path,omitempty"` + BinaryPathSource string `json:"binary_path_source,omitempty"` + PathBinaryPath string `json:"path_binary_path,omitempty"` + BinaryVersion string `json:"binary_version,omitempty"` + Checks []Check `json:"checks"` } type Check struct { @@ -60,6 +69,20 @@ type Check struct { NonFixable bool `json:"non_fixable,omitempty"` } +type onboardingBinaryResult struct { + Check Check + BinaryPath string + BinaryPathSource string + PathBinaryPath string + BinaryVersion string +} + +type binaryResolution struct { + BinaryPath string + BinaryPathSource string + PathBinaryPath string +} + var requiredSchemaPaths = []string{ "schemas/v1/runpack/manifest.schema.json", "schemas/v1/runpack/run.schema.json", @@ -112,6 +135,7 @@ func Run(opts Options) Result { } onboardingMode := detectOnboardingMode(workDir) + onboardingBinary := onboardingBinaryResult{} var checks []Check if opts.ProductionReadiness { @@ -133,11 +157,12 @@ func Run(opts Options) Result { withScope(checkTempDirWritable(), checkScopeUniversal), withScope(checkRegistryCacheHealth(), checkScopeUniversal), withScope(checkRateLimitLock(outputDir), checkScopeUniversal), - withScope(checkOnboardingBinary(workDir), checkScopeUniversal), withScope(checkKeySourceAmbiguity(opts.KeyConfig), checkScopeUniversal), withScope(checkKeyFilePermissions(opts.KeyConfig), checkScopeUniversal), withScope(checkKeyConfig(opts.KeyMode, opts.KeyConfig), checkScopeUniversal), } + onboardingBinary = checkOnboardingBinary(workDir, opts.InvokedBinaryPath) + checks = append(checks[:6], append([]Check{withScope(onboardingBinary.Check, checkScopeUniversal)}, checks[6:]...)...) if onboardingMode == onboardingModeRepoCheckout { checks = append(checks, withScope(checkSchemaFiles(workDir), checkScopeRepoCheckout), @@ -188,16 +213,20 @@ func Run(opts Options) Result { ) return Result{ - SchemaID: "gait.doctor.result", - SchemaVersion: "1.0.0", - CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), - ProducerVersion: producerVersion, - OnboardingMode: onboardingMode, - Status: status, - NonFixable: nonFixable, - Summary: summary, - FixCommands: fixCommands, - Checks: checks, + SchemaID: "gait.doctor.result", + SchemaVersion: "1.0.0", + CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + ProducerVersion: producerVersion, + OnboardingMode: onboardingMode, + Status: status, + NonFixable: nonFixable, + Summary: summary, + FixCommands: fixCommands, + BinaryPath: onboardingBinary.BinaryPath, + BinaryPathSource: onboardingBinary.BinaryPathSource, + PathBinaryPath: onboardingBinary.PathBinaryPath, + BinaryVersion: onboardingBinary.BinaryVersion, + Checks: checks, } } @@ -589,50 +618,63 @@ func checkKeySourceAmbiguity(cfg sign.KeyConfig) Check { } } -func checkOnboardingBinary(workDir string) Check { - binaryPath, err := findGaitBinaryPath(workDir) +func checkOnboardingBinary(workDir, invokedBinaryPath string) onboardingBinaryResult { + resolution, err := findGaitBinaryPath(workDir, invokedBinaryPath) if err != nil { - return Check{ - Name: "onboarding_binary", - Status: statusWarn, - Message: "gait binary not found; onboarding commands may fail", - FixCommand: "go build -o ./gait ./cmd/gait", + return onboardingBinaryResult{ + Check: Check{ + Name: "onboarding_binary", + Status: statusWarn, + Message: "gait binary not found; onboarding commands may fail", + FixCommand: "go build -o ./gait ./cmd/gait", + }, } } - info, err := os.Stat(binaryPath) + result := onboardingBinaryResult{ + BinaryPath: resolution.BinaryPath, + BinaryPathSource: resolution.BinaryPathSource, + PathBinaryPath: resolution.PathBinaryPath, + } + + info, err := os.Stat(resolution.BinaryPath) if err != nil || info.IsDir() { - return Check{ + result.Check = Check{ Name: "onboarding_binary", Status: statusWarn, Message: "gait binary path is not accessible", FixCommand: "go build -o ./gait ./cmd/gait", } + return result } - if !isExecutableBinary(binaryPath, info) { - return Check{ + if !isExecutableBinary(resolution.BinaryPath, info) { + result.Check = Check{ Name: "onboarding_binary", Status: statusWarn, - Message: fmt.Sprintf("gait binary is not executable: %s", binaryPath), - FixCommand: fmt.Sprintf("chmod +x %s", shellQuote(binaryPath)), + Message: fmt.Sprintf("gait binary is not executable: %s", resolution.BinaryPath), + FixCommand: fmt.Sprintf("chmod +x %s", shellQuote(resolution.BinaryPath)), } + return result } - versionOutput, versionErr := readGaitVersion(binaryPath) + versionOutput, versionErr := readGaitVersion(resolution.BinaryPath) if versionErr != nil { - return Check{ + result.Check = Check{ Name: "onboarding_binary", Status: statusWarn, - Message: fmt.Sprintf("gait binary version check failed (%s): %v", binaryPath, versionErr), + Message: fmt.Sprintf("gait binary version check failed (%s): %v", resolution.BinaryPath, versionErr), FixCommand: "go build -o ./gait ./cmd/gait", } + return result } - return Check{ + result.BinaryVersion = versionOutput + result.Check = Check{ Name: "onboarding_binary", Status: statusPass, - Message: fmt.Sprintf("gait binary ready (path=%s version=%s)", binaryPath, versionOutput), + Message: formatOnboardingBinaryMessage(resolution, versionOutput), } + return result } func checkOnboardingAssets(workDir string) Check { @@ -1006,21 +1048,97 @@ func checkKeyFilePermissions(cfg sign.KeyConfig) Check { } } -func findGaitBinaryPath(workDir string) (string, error) { +func findGaitBinaryPath(workDir, invokedBinaryPath string) (binaryResolution, error) { + if path, ok := normalizeBinaryPath(invokedBinaryPath); ok { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + resolution := binaryResolution{ + BinaryPath: path, + BinaryPathSource: binaryPathSourceInvokedExecutable, + } + if pathBinary, err := exec.LookPath("gait"); err == nil { + pathBinary = filepath.Clean(pathBinary) + if !sameBinaryPath(path, pathBinary) { + resolution.PathBinaryPath = pathBinary + } + } + return resolution, nil + } + } + if path, err := exec.LookPath("gait"); err == nil { - return path, nil + return binaryResolution{ + BinaryPath: filepath.Clean(path), + BinaryPathSource: binaryPathSourcePath, + }, nil } + for _, candidate := range workDirBinaryCandidates(workDir) { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return binaryResolution{ + BinaryPath: candidate, + BinaryPathSource: binaryPathSourceWorkDir, + }, nil + } + } + return binaryResolution{}, fmt.Errorf("gait binary not found") +} + +func workDirBinaryCandidates(workDir string) []string { candidates := []string{ filepath.Join(workDir, "gait"), filepath.Join(workDir, "gait.exe"), } - for _, candidate := range candidates { - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate, nil + if runtime.GOOS == "windows" { + candidates = append(candidates, + filepath.Join(workDir, "gait.cmd"), + filepath.Join(workDir, "gait.bat"), + ) + } + return candidates +} + +func normalizeBinaryPath(path string) (string, bool) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", false + } + if !filepath.IsAbs(trimmed) { + absolute, err := filepath.Abs(trimmed) + if err != nil { + return filepath.Clean(trimmed), true } + return filepath.Clean(absolute), true + } + return filepath.Clean(trimmed), true +} + +func sameBinaryPath(leftPath, rightPath string) bool { + leftInfo, leftErr := os.Stat(leftPath) + rightInfo, rightErr := os.Stat(rightPath) + if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) { + return true + } + + leftClean := filepath.Clean(leftPath) + rightClean := filepath.Clean(rightPath) + if runtime.GOOS == "windows" { + return strings.EqualFold(leftClean, rightClean) + } + return leftClean == rightClean +} + +func formatOnboardingBinaryMessage(resolution binaryResolution, version string) string { + switch resolution.BinaryPathSource { + case binaryPathSourceInvokedExecutable: + if resolution.PathBinaryPath != "" { + return fmt.Sprintf("gait binary ready (invoked_path=%s version=%s path_binary=%s)", resolution.BinaryPath, version, resolution.PathBinaryPath) + } + return fmt.Sprintf("gait binary ready (invoked_path=%s version=%s)", resolution.BinaryPath, version) + case binaryPathSourceWorkDir: + return fmt.Sprintf("gait binary ready (workdir_path=%s version=%s)", resolution.BinaryPath, version) + default: + return fmt.Sprintf("gait binary ready (path=%s version=%s)", resolution.BinaryPath, version) } - return "", fmt.Errorf("gait binary not found") } func isExecutableBinary(path string, info os.FileInfo) bool { diff --git a/core/doctor/doctor_test.go b/core/doctor/doctor_test.go index 946adb3..ab3b410 100644 --- a/core/doctor/doctor_test.go +++ b/core/doctor/doctor_test.go @@ -166,6 +166,95 @@ func TestRunPassesWithValidWorkspaceAndSchemas(t *testing.T) { } } +func TestRunPrefersInvokedBinaryAndReportsPATHCollision(t *testing.T) { + workDir := t.TempDir() + outputDir := filepath.Join(workDir, "gait-out") + if err := ensureDir(outputDir); err != nil { + t.Fatalf("create output dir: %v", err) + } + + pathDir := t.TempDir() + pathBinary := writeFakeGaitBinary(t, pathDir, "gait", "gait 0.0.0-path") + invokedBinary := writeFakeGaitBinary(t, t.TempDir(), "gait", "gait 0.0.0-invoked") + t.Setenv("PATH", pathDir) + + result := Run(Options{ + WorkDir: workDir, + OutputDir: outputDir, + ProducerVersion: "test", + KeyMode: sign.ModeDev, + InvokedBinaryPath: invokedBinary, + }) + + if result.BinaryPath != invokedBinary { + t.Fatalf("expected invoked binary path, got %q", result.BinaryPath) + } + if result.BinaryPathSource != binaryPathSourceInvokedExecutable { + t.Fatalf("expected invoked executable source, got %q", result.BinaryPathSource) + } + if result.PathBinaryPath != pathBinary { + t.Fatalf("expected PATH binary path %q, got %q", pathBinary, result.PathBinaryPath) + } + if result.BinaryVersion != "gait 0.0.0-invoked" { + t.Fatalf("expected invoked binary version, got %q", result.BinaryVersion) + } + + check := findCheck(result.Checks, "onboarding_binary") + if check == nil { + t.Fatalf("expected onboarding_binary check") + } + if check.Status != statusPass { + t.Fatalf("expected onboarding_binary pass, got %#v", check) + } + if !strings.Contains(check.Message, "invoked_path="+invokedBinary) { + t.Fatalf("expected invoked path in message, got %q", check.Message) + } + if !strings.Contains(check.Message, "path_binary="+pathBinary) { + t.Fatalf("expected PATH collision in message, got %q", check.Message) + } +} + +func TestRunFallsBackToWorkDirBinaryWhenPATHMissing(t *testing.T) { + workDir := t.TempDir() + outputDir := filepath.Join(workDir, "gait-out") + if err := ensureDir(outputDir); err != nil { + t.Fatalf("create output dir: %v", err) + } + workDirBinary := writeFakeGaitBinary(t, workDir, "gait", "gait 0.0.0-workdir") + t.Setenv("PATH", "") + + result := Run(Options{ + WorkDir: workDir, + OutputDir: outputDir, + ProducerVersion: "test", + KeyMode: sign.ModeDev, + }) + + if result.BinaryPath != workDirBinary { + t.Fatalf("expected workdir binary path, got %q", result.BinaryPath) + } + if result.BinaryPathSource != binaryPathSourceWorkDir { + t.Fatalf("expected workdir source, got %q", result.BinaryPathSource) + } + if result.PathBinaryPath != "" { + t.Fatalf("expected empty PATH binary path, got %q", result.PathBinaryPath) + } + if result.BinaryVersion != "gait 0.0.0-workdir" { + t.Fatalf("expected workdir binary version, got %q", result.BinaryVersion) + } + + check := findCheck(result.Checks, "onboarding_binary") + if check == nil { + t.Fatalf("expected onboarding_binary check") + } + if check.Status != statusPass { + t.Fatalf("expected onboarding_binary pass, got %#v", check) + } + if !strings.Contains(check.Message, "workdir_path="+workDirBinary) { + t.Fatalf("expected workdir path in message, got %q", check.Message) + } +} + func TestRunDetectsDurableStateDivergence(t *testing.T) { installFakeGaitBinaryInPath(t) @@ -359,20 +448,20 @@ func TestOnboardingChecks(t *testing.T) { workDir := t.TempDir() t.Setenv("PATH", "") - check := checkOnboardingBinary(workDir) - if check.Status != statusWarn { - t.Fatalf("expected onboarding binary warning, got %#v", check) + binaryCheck := checkOnboardingBinary(workDir, "") + if binaryCheck.Check.Status != statusWarn { + t.Fatalf("expected onboarding binary warning, got %#v", binaryCheck) } - if !strings.Contains(check.FixCommand, "go build -o ./gait ./cmd/gait") { - t.Fatalf("unexpected binary fix command: %#v", check) + if !strings.Contains(binaryCheck.Check.FixCommand, "go build -o ./gait ./cmd/gait") { + t.Fatalf("unexpected binary fix command: %#v", binaryCheck) } - check = checkOnboardingAssets(workDir) - if check.Status != statusWarn { - t.Fatalf("expected onboarding assets warning, got %#v", check) + assetsCheck := checkOnboardingAssets(workDir) + if assetsCheck.Status != statusWarn { + t.Fatalf("expected onboarding assets warning, got %#v", assetsCheck) } - if !strings.Contains(check.FixCommand, "git restore --source=HEAD -- scripts/quickstart.sh examples/integrations") { - t.Fatalf("unexpected assets fix command: %#v", check) + if !strings.Contains(assetsCheck.FixCommand, "git restore --source=HEAD -- scripts/quickstart.sh examples/integrations") { + t.Fatalf("unexpected assets fix command: %#v", assetsCheck) } quickstartPath := filepath.Join(workDir, "scripts", "quickstart.sh") @@ -396,21 +485,21 @@ func TestOnboardingChecks(t *testing.T) { } } - check = checkOnboardingAssets(workDir) + assetsCheck = checkOnboardingAssets(workDir) if runtime.GOOS == "windows" { - if check.Status != statusPass { - t.Fatalf("expected onboarding assets pass on windows, got %#v", check) + if assetsCheck.Status != statusPass { + t.Fatalf("expected onboarding assets pass on windows, got %#v", assetsCheck) } - } else if check.Status != statusWarn || !strings.Contains(check.FixCommand, "chmod +x scripts/quickstart.sh") { - t.Fatalf("expected onboarding quickstart chmod warning, got %#v", check) + } else if assetsCheck.Status != statusWarn || !strings.Contains(assetsCheck.FixCommand, "chmod +x scripts/quickstart.sh") { + t.Fatalf("expected onboarding quickstart chmod warning, got %#v", assetsCheck) } if err := os.Chmod(quickstartPath, 0o755); err != nil { t.Fatalf("chmod quickstart: %v", err) } - check = checkOnboardingAssets(workDir) - if check.Status != statusPass { - t.Fatalf("expected onboarding assets pass, got %#v", check) + assetsCheck = checkOnboardingAssets(workDir) + if assetsCheck.Status != statusPass { + t.Fatalf("expected onboarding assets pass, got %#v", assetsCheck) } } @@ -724,6 +813,15 @@ func ensureDir(path string) error { return os.MkdirAll(path, 0o750) } +func findCheck(checks []Check, name string) *Check { + for index := range checks { + if checks[index].Name == name { + return &checks[index] + } + } + return nil +} + func repoRoot(t *testing.T) string { t.Helper() _, filename, _, ok := runtime.Caller(0) @@ -738,16 +836,24 @@ func installFakeGaitBinaryInPath(t *testing.T) { t.Helper() binDir := t.TempDir() - binPath := filepath.Join(binDir, "gait") - content := "#!/bin/sh\nif [ \"$1\" = \"version\" ]; then\n echo \"gait 0.0.0-test\"\n exit 0\nfi\necho \"gait 0.0.0-test\"\n" + writeFakeGaitBinary(t, binDir, "gait", "gait 0.0.0-test") + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func writeFakeGaitBinary(t *testing.T, dir, baseName, version string) string { + t.Helper() + + binPath := filepath.Join(dir, baseName) + content := "#!/bin/sh\nif [ \"$1\" = \"version\" ]; then\n echo \"" + version + "\"\n exit 0\nfi\necho \"" + version + "\"\n" mode := os.FileMode(0o755) if runtime.GOOS == "windows" { - binPath = filepath.Join(binDir, "gait.cmd") - content = "@echo off\r\nif \"%1\"==\"version\" (\r\n echo gait 0.0.0-test\r\n exit /b 0\r\n)\r\necho gait 0.0.0-test\r\n" + baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName)) + binPath = filepath.Join(dir, baseName+".cmd") + content = "@echo off\r\nif \"%1\"==\"version\" (\r\n echo " + version + "\r\n exit /b 0\r\n)\r\necho " + version + "\r\n" mode = 0o600 } if err := os.WriteFile(binPath, []byte(content), mode); err != nil { t.Fatalf("write fake gait binary: %v", err) } - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + return binPath } diff --git a/core/runpack/verify.go b/core/runpack/verify.go index b9bef31..95f11cb 100644 --- a/core/runpack/verify.go +++ b/core/runpack/verify.go @@ -55,8 +55,9 @@ func VerifyZip(path string, opts VerifyOptions) (VerifyResult, error) { if err := rejectDuplicateZipEntries(zipReader.File); err != nil { return VerifyResult{}, err } + filesByPath := indexZipFiles(zipReader.File) - manifestFile, manifestFound := findZipFile(zipReader.File, "manifest.json") + manifestFile, manifestFound := filesByPath["manifest.json"] if !manifestFound { return VerifyResult{}, fmt.Errorf("missing manifest.json") } @@ -103,7 +104,7 @@ func VerifyZip(path string, opts VerifyOptions) (VerifyResult, error) { case "refs.json": hasRefs = true } - zipFile, exists := findZipFile(zipReader.File, name) + zipFile, exists := filesByPath[name] if !exists { result.MissingFiles = append(result.MissingFiles, name) continue @@ -219,6 +220,14 @@ func signableManifestBytes(manifest []byte) ([]byte, error) { return json.Marshal(obj) } +func indexZipFiles(files []*zip.File) map[string]*zip.File { + index := make(map[string]*zip.File, len(files)) + for _, zipFile := range files { + index[filepath.ToSlash(zipFile.Name)] = zipFile + } + return index +} + func findZipFile(files []*zip.File, name string) (*zip.File, bool) { for _, zipFile := range files { if filepath.ToSlash(zipFile.Name) == name { diff --git a/core/runpack/verify_test.go b/core/runpack/verify_test.go index dc30bb7..d2628a4 100644 --- a/core/runpack/verify_test.go +++ b/core/runpack/verify_test.go @@ -317,6 +317,71 @@ func TestVerifyZipSignedDigestMismatch(test *testing.T) { } } +func TestVerifyZipSignatureUsesRawManifestBytes(test *testing.T) { + keyPair, err := sign.GenerateKeyPair() + if err != nil { + test.Fatalf("generate keypair: %v", err) + } + manifestFiles, runpackFiles := buildCompleteRunpackFixture() + unsignedManifest, err := buildManifestBytes("run_test", manifestFiles, nil) + if err != nil { + test.Fatalf("build manifest: %v", err) + } + + mutatedManifest := bytes.Replace( + unsignedManifest, + []byte(`"created_at":"2026-02-05T00:00:00Z"`), + []byte(`"created_at":"2026-02-05T00:00:00+00:00"`), + 1, + ) + mutatedManifest = bytes.Replace( + mutatedManifest, + []byte(`"manifest_digest"`), + []byte(`"producer_note":"kept","manifest_digest"`), + 1, + ) + + signable, err := signableManifestBytes(mutatedManifest) + if err != nil { + test.Fatalf("signable manifest: %v", err) + } + signature, err := sign.SignManifestJSON(keyPair.Private, signable) + if err != nil { + test.Fatalf("sign manifest: %v", err) + } + signaturesJSON, err := json.Marshal([]schemarunpack.Signature{{ + Alg: signature.Alg, + KeyID: signature.KeyID, + Sig: signature.Sig, + SignedDigest: signature.SignedDigest, + }}) + if err != nil { + test.Fatalf("marshal signatures: %v", err) + } + signedManifest := append(append(mutatedManifest[:len(mutatedManifest)-1], []byte(`,"signatures":`)...), signaturesJSON...) + signedManifest = append(signedManifest, '}') + + archiveFiles := append([]zipx.File{ + {Path: "manifest.json", Data: signedManifest, Mode: 0o644}, + }, runpackFiles...) + zipPath := writeRunpackZip(test, archiveFiles) + + result, err := VerifyZip(zipPath, VerifyOptions{ + PublicKey: keyPair.Public, + RequireSignature: true, + SkipManifestDigestCheck: true, + }) + if err != nil { + test.Fatalf("verify zip: %v", err) + } + if result.SignatureStatus != "verified" { + test.Fatalf("expected verified signature, got %s (%v)", result.SignatureStatus, result.SignatureErrors) + } + if len(result.SignatureErrors) != 0 { + test.Fatalf("expected no signature errors, got %v", result.SignatureErrors) + } +} + func TestVerifyZipManifestMissingRunID(test *testing.T) { manifestFiles, runpackFiles := buildCompleteRunpackFixture() manifestBytes, err := buildManifestBytes("", manifestFiles, nil) diff --git a/core/scout/snapshot.go b/core/scout/snapshot.go index b57c24f..dee519d 100644 --- a/core/scout/snapshot.go +++ b/core/scout/snapshot.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "hash" "io/fs" "os" "path/filepath" @@ -16,7 +17,6 @@ import ( schemacommon "github.com/Clyra-AI/gait/core/schema/v1/common" schemascout "github.com/Clyra-AI/gait/core/schema/v1/scout" - jcs "github.com/Clyra-AI/proof/canon" "github.com/goccy/go-yaml" ) @@ -352,22 +352,25 @@ func collectMCPServerNames(value any, names map[string]struct{}) { } func computeSnapshotID(workspace string, items []schemascout.InventoryItem) (string, error) { - payload := struct { - Workspace string `json:"workspace"` - Items []schemascout.InventoryItem `json:"items"` - }{ - Workspace: workspace, - Items: items, - } - encoded, err := json.Marshal(payload) - if err != nil { - return "", err - } - digest, err := jcs.DigestJCS(encoded) - if err != nil { - return "", err + digest := sha256.New() + writeSnapshotIDField(digest, workspace) + for _, item := range items { + writeSnapshotIDField(digest, item.ID) + writeSnapshotIDField(digest, item.Kind) + writeSnapshotIDField(digest, item.Name) + writeSnapshotIDField(digest, item.Locator) + writeSnapshotIDField(digest, item.RiskLevel) + for _, tag := range item.Tags { + writeSnapshotIDField(digest, tag) + } + digest.Write([]byte{0xff}) } - return "snap_" + digest[:12], nil + return "snap_" + hex.EncodeToString(digest.Sum(nil)[:6]), nil +} + +func writeSnapshotIDField(digest hash.Hash, value string) { + digest.Write([]byte(value)) + digest.Write([]byte{0}) } func classifyRisk(name string) string { diff --git a/docs-site/public/llm/faq.md b/docs-site/public/llm/faq.md index d50d1ff..405c477 100644 --- a/docs-site/public/llm/faq.md +++ b/docs-site/public/llm/faq.md @@ -10,6 +10,8 @@ Run `gait version --json`, `gait doctor --json`, `gait init --json`, `gait check `gait doctor --json` stays install-safe in a clean writable directory. `gait init --json` returns `detected_signals`, conservative `generated_rules`, and `unknown_signals`. `gait check --json` reports structured `findings` and install-safe `next_commands` in addition to compatibility `gap_warnings`. +Treat that as fast proof. Strict inline enforcement is a separate step at the real tool boundary, and hardened `oss-prod` claims require `gait doctor --production-readiness --json`. + ## What problem does Gait solve for long-running agent work? Multi-step and multi-hour agent jobs fail mid-flight, losing state and provenance. Gait dispatches durable jobs with checkpointed state, pause/resume/stop/cancel, and deterministic stop reasons so work survives failures and stays auditable. diff --git a/docs-site/public/llm/product.md b/docs-site/public/llm/product.md index 379effe..f22f9dc 100644 --- a/docs-site/public/llm/product.md +++ b/docs-site/public/llm/product.md @@ -2,20 +2,24 @@ Gait is the offline-first policy-as-code runtime for AI agent tool calls. It bootstraps repo policy with `gait init` and `gait check`, enforces fail-closed verdicts at the tool boundary, captures signed evidence, and turns incidents into deterministic CI regressions. +Supporting promise: prove the install fast, enforce at the tool boundary when you own the seam, and graduate to hardened `oss-prod` readiness explicitly. + Repo bootstrap stays machine-readable: `gait doctor --json` is truthful for binary installs, `gait init --json` returns repo `detected_signals`, conservative `generated_rules`, and `unknown_signals`, and `gait check --json` reports structured `findings` plus install-safe `next_commands` for readiness follow-up. -It provides seven OSS primitives: +Primary adoption checkpoints: -1. **Gate**: Evaluate structured tool-call intent against YAML policy with fail-closed enforcement. Supports destructive plan/apply boundaries, destructive budgets, multi-step script rollups, Wrkr context enrichment, and signed approved-script fast-path allow. -2. **Evidence**: Signed traces, runpacks, packs, and callpacks with Ed25519 signatures and SHA-256 manifests. -3. **Regress**: Convert incidents into deterministic CI regression fixtures with JUnit output and stable exit codes through `gait capture`, `gait regress add`, and `gait regress bootstrap`. -4. **Jobs**: Dispatch multi-step, multi-hour agent work with checkpoints, pause/resume/stop/cancel, approval gates, deterministic stop reasons, and emergency-stop preemption evidence. -5. **Voice**: Gate high-stakes spoken commitments before they are uttered with SayToken capability tokens and callpack artifacts. -6. **Context Evidence**: Deterministic proof of what context the model was working from at decision time. Privacy-aware envelopes with fail-closed enforcement when evidence is missing, and context-required gate checks bind digest/mode/freshness from a verified `--context-envelope`. -7. **Doctor**: Diagnose first-run environment issues with stable JSON output. `gait doctor --production-readiness --json` is the explicit gate before claiming high-risk `oss-prod` readiness. +1. **Fast proof**: `gait version --json`, `gait doctor --json`, `gait demo`, `gait verify run_demo --json`, and `gait regress bootstrap --from run_demo --json --junit ...` validate install, evidence, and CI wiring. +2. **Strict inline enforcement**: place Gait at the real wrapper, sidecar, middleware, or MCP execution seam so non-`allow` outcomes do not execute side effects. +3. **Hardened `oss-prod` readiness**: seed `examples/config/oss_prod_template.yaml`, run `gait check --json`, then require `gait doctor --production-readiness --json` to return `ok=true`. Secondary boundary surfaces: +- **Doctor**: truthful first-run diagnostics and the explicit production-readiness gate. +- **Evidence**: signed traces, runpacks, packs, and callpacks with Ed25519 signatures and SHA-256 manifests. +- **Regress**: convert incidents into deterministic CI regression fixtures with JUnit output and stable exit codes through `gait capture`, `gait regress add`, and `gait regress bootstrap`. +- **Jobs**: dispatch multi-step, multi-hour agent work with checkpoints, pause/resume/stop/cancel, approval gates, deterministic stop reasons, and emergency-stop preemption evidence. +- **Voice**: gate high-stakes spoken commitments before they are uttered with SayToken capability tokens and callpack artifacts. +- **Context Evidence**: deterministic proof of what context the model was working from at decision time, with fail-closed checks bound to a verified `--context-envelope`. - **MCP Trust**: evaluate local trust snapshots for MCP server admission with `gait mcp verify`, `gait mcp proxy`, and `gait mcp serve`. - **Trace**: observe-only wrapper mode with `gait trace` for integrations that already emit Gait trace references. - **LangChain Middleware**: official Python middleware with optional callback correlation; callbacks never decide allow or block behavior, and demo capture stays bound to `gait demo --json`. @@ -35,6 +39,7 @@ Tool boundary (canonical): - exact call site where runtime is about to execute a real tool side effect - adapter sends structured intent to Gait - only `allow` executes tool side effects; non-allow outcomes are non-executing +- quickstart/demo proof is valuable without that seam, but it is not evidence of strict inline enforcement by itself When to use: diff --git a/docs-site/public/llm/quickstart.md b/docs-site/public/llm/quickstart.md index 4c07bb8..c2b212a 100644 --- a/docs-site/public/llm/quickstart.md +++ b/docs-site/public/llm/quickstart.md @@ -2,6 +2,8 @@ Use this when you need deterministic control plus evidence at agent tool boundaries. +Treat this page as the fast-proof lane first. Strict inline fail-closed enforcement starts only when you place Gait at a real tool boundary, and hardened `oss-prod` claims come later with `gait doctor --production-readiness --json`. + ```bash # Install curl -fsSL https://raw.githubusercontent.com/Clyra-AI/gait/main/scripts/install.sh | bash @@ -82,6 +84,8 @@ Do not treat `oss-prod` enforcement as production-ready until that doctor comman Standard `gait doctor --json` is truthful in a clean writable directory after a binary-only install: repo-only schema/example checks stay scoped to a Gait repo checkout. +That proof is not the same thing as runtime blocking. Managed or preloaded runtimes without an interception seam can still use the quickstart, but they should stay in the proof, observe, capture, and regress lane until the boundary is under user control. + Reference boundary demo: ```bash diff --git a/docs-site/public/llms.txt b/docs-site/public/llms.txt index 1c43df3..891d264 100644 --- a/docs-site/public/llms.txt +++ b/docs-site/public/llms.txt @@ -1,6 +1,6 @@ # Gait -> Offline-first policy-as-code runtime for AI agent tool calls: bootstrap `.gait.yaml` with `gait init` and `gait check`, enforce fail-closed verdicts at the execution boundary, keep signed evidence, and turn incidents into deterministic CI regressions. +> Gait is the offline-first policy-as-code runtime for AI agent tool calls. Prove the install fast, enforce at the tool boundary when you own the seam, and graduate to hardened `oss-prod` readiness explicitly. ## Canonical - https://github.com/Clyra-AI/gait @@ -39,6 +39,7 @@ - Boundary location: the exact adapter call site before real tool side effects execute. - Input: structured intent payload. - Rule: `verdict != allow` means no tool execution. +- Fast proof (`gait doctor --json`, `gait demo`, `gait regress bootstrap`) is not the same thing as strict inline enforcement. - Context-required policies must receive `--context-envelope ` at that boundary; raw context digest/mode/age claims are not authoritative alone. - For `gait mcp serve`, same-host callers may use `call.context.context_envelope_path` only when the server explicitly enables `--allow-client-artifact-paths`. - Official LangChain lane: middleware with optional callback correlation, not a separate policy engine. @@ -64,6 +65,7 @@ - Python run-session capture delegates digest-bearing artifact fields to `gait run record` in Go; convert `set` values to JSON lists before calling the SDK. - `examples/integrations/claude_code/` is a reference adapter; hook/runtime/input errors fail closed by default and `GAIT_CLAUDE_UNSAFE_FAIL_OPEN=1` is an unsafe opt-in override. - `gait doctor --json` is install-safe in a clean writable directory: it returns the installed-binary lane with `status=pass|warn` and only adds repo-only checks in a Gait repo checkout. +- High-risk `oss-prod` claims require the separate readiness gate: `gait doctor --production-readiness --json`. - High-risk enforcement is not production-ready until `gait doctor --production-readiness --json` returns `ok=true`, typically from config seeded by `examples/config/oss_prod_template.yaml` from a repo checkout or by fetching that same file after a binary-only install. - `gait init` writes `.gait.yaml`; `gait check` reports the live policy contract and gap warnings. - Repo bootstrap JSON: `gait init --json` returns `detected_signals`, conservative `generated_rules`, and `unknown_signals`; `gait check --json` returns structured `findings`, compatibility `gap_warnings`, and install-safe `next_commands`. diff --git a/docs/README.md b/docs/README.md index 2248726..5096ea3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,14 @@ This file defines where each topic lives so docs stay accurate and non-duplicative. +Primary category line: + +- Gait is the offline-first policy-as-code runtime for AI agent tool calls. + +Supporting promise line: + +- Prove the install fast, enforce at the tool boundary when you own the seam, and graduate to hardened `oss-prod` readiness explicitly. + ## Canonical Surface Taxonomy Core surfaces: @@ -72,7 +80,7 @@ Extended first-class surfaces: - Prime-time runbook: `docs/hardening/prime_time_runbook.md` - Runtime SLOs: `docs/slo/runtime_slo.md` - Retention profiles: `docs/slo/retention_profiles.md` -- Maintainer CI/runtime policy and local workflow guard: `CONTRIBUTING.md` +- Maintainer CI/runtime policy and local workflow guard: `CONTRIBUTING.md` (checkout, setup-go, setup-python, setup-node, and CodeQL action majors) - CI regress runbook: `docs/ci_regress_kit.md` - One-PR adoption page: `docs/adopt_in_one_pr.md` - Threat model: `docs/threat_model.md` diff --git a/docs/adopt_in_one_pr.md b/docs/adopt_in_one_pr.md index aa7f979..04dc4cf 100644 --- a/docs/adopt_in_one_pr.md +++ b/docs/adopt_in_one_pr.md @@ -154,7 +154,7 @@ If you want a passing lane after validating setup, remove the forced-drift step. Maintainer runtime policy: -- Keep published GitHub Actions examples on the repo's Node 24-safe baseline, including `actions/checkout@v5` or newer. +- Keep published GitHub Actions examples and repo workflow docs on the repo's Node 24-safe baseline, including `actions/checkout@v5` and `actions/setup-node@v5` or newer where they appear. - Run `python3 scripts/check_github_action_runtime_versions.py .github/workflows docs/adopt_in_one_pr.md` before opening a PR that changes workflow YAML or this example. - Treat `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true` and `ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true` as emergency-only compatibility toggles, not the normal remediation path. diff --git a/docs/agent_integration_boundary.md b/docs/agent_integration_boundary.md index 2daf1d2..35be579 100644 --- a/docs/agent_integration_boundary.md +++ b/docs/agent_integration_boundary.md @@ -13,6 +13,8 @@ Fail-closed enforcement requires an interception point before real tool executio If you cannot intercept tool calls, Gait can still add observe/report/regress value, but it cannot block side effects inline. +Fast proof commands such as `gait doctor --json`, `gait demo`, and `gait regress bootstrap --from run_demo --json --junit ./gait-out/junit.xml` are still useful without interception. Treat them as install, artifact, and CI proof, not as proof of strict inline blocking. + Concrete boundary touchpoints: - call `gait gate eval` at the final adapter or middleware dispatch site before execution @@ -89,6 +91,11 @@ What Gait cannot do (without interception): 3. If partially: use Tier B and close bypass paths first. 4. If no: use Tier C and treat controls as observe/report/regress until interception is available. +Production-readiness note: + +- `gait doctor --production-readiness --json` is the hardening gate for high-risk `oss-prod` claims after the boundary is in place. +- It does not replace the need for a real interception seam. + ## Practical Paths - Tier A reference demo quickstart: `examples/integrations/openai_agents/quickstart.py` diff --git a/docs/contracts/primitive_contract.md b/docs/contracts/primitive_contract.md index 9c7eada..6c36881 100644 --- a/docs/contracts/primitive_contract.md +++ b/docs/contracts/primitive_contract.md @@ -32,6 +32,7 @@ These file locations and command roles are part of the public OSS onboarding con Onboarding JSON obligations: - `gait doctor --json` MUST remain truthful for the installed-binary lane and MAY add fields such as `onboarding_mode` or per-check `scope` only additively. +- `gait doctor --json` MAY add machine-readable binary provenance fields only additively, including `binary_path`, `binary_path_source`, `binary_version`, and `path_binary_path` when the invoked executable differs from the PATH-resolved `gait`. - `gait init --json` and `gait check --json` `next_commands` MUST be executable in the environment they are emitted for, or MUST include an explicit repo/fetch step in the command text. Canonical migration notes: diff --git a/docs/hardening/release_checklist.md b/docs/hardening/release_checklist.md index 9bc2031..e096f20 100644 --- a/docs/hardening/release_checklist.md +++ b/docs/hardening/release_checklist.md @@ -10,6 +10,10 @@ Use this checklist before creating a release tag. Items marked "MANDATORY" are r - Python coverage >= 85% - [ ] `make test-hardening-acceptance` passes. - [ ] `make test-install-path-versions` passes. +- [ ] Docs and release-story gates pass: + - `make test-docs-consistency` + - `make docs-site-check` + - `make test-release-smoke` - [ ] Versioned acceptance/context gates pass: - `make test-v2-3-acceptance` - `make test-v2-4-acceptance` @@ -69,7 +73,13 @@ Use this checklist before creating a release tag. Items marked "MANDATORY" are r ## 6) Operational Readiness (RECOMMENDED) - [ ] `gait doctor --json` includes green checks for hooks, cache, lock staleness, temp writeability, and key-source ambiguity. +- [ ] `gait doctor --json` reports the invoked binary truthfully and only surfaces `path_binary_path` when a different PATH `gait` is present. +- [ ] `python3 scripts/check_github_action_runtime_versions.py .github/workflows docs/adopt_in_one_pr.md` passes, including `actions/setup-node`. - [ ] Install and release docs point operators to `examples/config/oss_prod_template.yaml` and require `gait doctor --production-readiness --json` before claiming `oss-prod` readiness. +- [ ] README, launch docs, and docs-site public LLM mirrors all keep the same staged launch story: + - fast proof + - strict inline enforcement only at a real interception seam + - hardened `oss-prod` readiness as a separate explicit gate - [ ] Installer/Homebrew/manual verification uses `gait version --json` as the machine-readable binary probe. - [ ] Correlation IDs and operational events are emitted in opt-in logs where enabled. - [ ] Homebrew tap install/test smoke passes for the release: @@ -84,4 +94,7 @@ Use this checklist before creating a release tag. Items marked "MANDATORY" are r - [ ] Release manager sign-off (engineering owner) - [ ] Security sign-off (if security-sensitive changes included) -- [ ] Go/No-Go recorded in release notes +- [ ] Go/No-Go recorded in release notes with: + - decision date + - evidence command list + - residual non-blocking risks or `none` diff --git a/docs/install.md b/docs/install.md index 8a24dc6..3616481 100644 --- a/docs/install.md +++ b/docs/install.md @@ -81,6 +81,16 @@ gait demo gait verify run_demo --json ``` +## Three Distinct Checkpoints + +Treat these as different promises, not one blended onboarding claim: + +1. Fast proof: `gait version --json`, `gait doctor --json`, `gait demo`, `gait verify run_demo --json` +2. Strict inline enforcement: wire `gait gate eval` or a Gait boundary wrapper before the real tool call executes +3. Hardened `oss-prod` readiness: seed `examples/config/oss_prod_template.yaml`, run `gait check --json`, then require `gait doctor --production-readiness --json` + +Managed or preloaded runtimes without an interception seam can still use the proof, capture, verify, and regress paths. They should not claim strict inline fail-closed blocking until the execution boundary is under user control. + ## Dev vs Prod Mode (Important) Use development mode for first-run validation: @@ -90,6 +100,16 @@ gait demo gait verify run_demo --json ``` +This is the install proof path. It validates the binary, evidence, and local artifact loop, but it is not the same thing as runtime enforcement. + +To move from proof to enforcement, place Gait at the real tool boundary: + +```bash +gait gate eval --policy .gait.yaml --intent ./intent.json --json +``` + +That boundary call must happen immediately before the side effect you want to control. + Before production use, apply hardened defaults and validate readiness: ```bash @@ -106,7 +126,7 @@ gait doctor --production-readiness --json Use `examples/config/oss_prod_template.yaml` as the canonical hardened starting point, whether you copy it from a repo checkout or fetch that same file after a binary-only install. Then review the environment variable names, listener, and retention values for your deployment before enforcing. High-risk runtime boundaries are not production-ready until `gait doctor --production-readiness --json` reports `ok=true`. -`gait doctor --json` is truthful for binary-only installs: in a clean writable directory it returns the installed-binary lane with `status=pass|warn` and skips repo-only schema/example checks unless you run it inside a Gait repo checkout. +`gait doctor --json` is truthful for binary-only installs: in a clean writable directory it returns the installed-binary lane with `status=pass|warn`, reports the checked executable via additive `binary_path`, `binary_path_source`, and `binary_version` fields, and only sets `path_binary_path` when a different PATH-resolved `gait` binary is also present. Repo-only schema/example checks stay scoped to a Gait repo checkout. If PATH is still not updated, run directly once: diff --git a/docs/integration_checklist.md b/docs/integration_checklist.md index 92af407..05abb5d 100644 --- a/docs/integration_checklist.md +++ b/docs/integration_checklist.md @@ -9,6 +9,12 @@ This checklist is for OSS teams integrating Gait at the tool-call boundary. Target: first deterministic wrapper integration in <= 15 minutes from clean checkout. +Keep these checkpoints separate while you read the rest of this guide: + +1. Fast proof: install, `gait doctor --json`, `gait demo`, verify, and regress bootstrap +2. Strict inline enforcement: a real wrapper, middleware, sidecar, or MCP seam before tool execution +3. Hardened `oss-prod` readiness: explicit config plus `gait doctor --production-readiness --json` + Fast path scenario: - `docs/scenarios/simple_agent_tool_boundary.md` @@ -66,6 +72,8 @@ Choose the highest tier you can support: 2. Partial interception: enforce where possible and treat uncovered paths as observe-only. 3. No interception (some managed/preloaded agent products): use observe/report/regress workflows; inline fail-closed enforcement is not guaranteed. +Do not collapse Tier 3 proof into Tier 1 enforcement language. Managed or preloaded products without the seam can still prove install correctness, artifact generation, and CI regression wiring, but they cannot honestly claim strict inline blocking. + Reference: `docs/agent_integration_boundary.md`. ## Boundary Touchpoints To Wire diff --git a/docs/launch/README.md b/docs/launch/README.md index 4dc4a09..8f627ce 100644 --- a/docs/launch/README.md +++ b/docs/launch/README.md @@ -4,6 +4,11 @@ This folder is the repeatable distribution package for OSS launch cycles. Use it when shipping a major release, announcing a wedge milestone, or running a re-launch. +Lead every launch asset with the same two lines: + +- Primary category line: Gait is the offline-first policy-as-code runtime for AI agent tool calls. +- Supporting promise line: Prove the install fast, enforce at the tool boundary when you own the seam, and graduate to hardened `oss-prod` readiness explicitly. + Current safe-default rollout note: - ship `examples/policy/knowledge_worker_safe.yaml` as reversible-first profile @@ -11,6 +16,7 @@ Current safe-default rollout note: - stage rollout: monitor -> approval -> enforce - when citing MCP trust, show local snapshot + `gait mcp verify`, not a hosted scanner replacement story - keep category language fixed: policy-as-code for agent tool calls, not control plane, dashboard, or framework replacement +- keep packs, jobs, voice, context evidence, and MCP trust as supporting surfaces under that wedge, not competing category lines ## Contents diff --git a/docs/launch/faq_objections.md b/docs/launch/faq_objections.md index c627c2e..23dcf97 100644 --- a/docs/launch/faq_objections.md +++ b/docs/launch/faq_objections.md @@ -7,6 +7,14 @@ Gait keeps one execution-boundary and artifact contract across all of them. It is also not a framework replacement. The framework keeps planning and orchestration; Gait owns the execution verdict and evidence contract. +## "Does the quickstart mean we're production-ready?" + +No. +The quickstart proves install, artifact generation, and CI regression wiring. + +Strict inline fail-closed enforcement starts only when Gait sits at a real tool boundary before execution. +Hardened `oss-prod` claims require the explicit readiness gate: `gait doctor --production-readiness --json`. + ## "Is this another prompt-injection scanner?" No. Gait enforces policy at tool-call execution boundary. diff --git a/docs/launch/github_release_template.md b/docs/launch/github_release_template.md index 86bb396..9227ffa 100644 --- a/docs/launch/github_release_template.md +++ b/docs/launch/github_release_template.md @@ -10,6 +10,10 @@ Use this structure for tagged releases. Gait is the offline-first policy-as-code runtime for AI agent tool calls. +Supporting promise: + +Prove the install fast, enforce at the tool boundary when you own the seam, and graduate to hardened `oss-prod` readiness explicitly. + ## What Shipped - `` with path references @@ -28,6 +32,8 @@ gait verify run_demo --json gait regress bootstrap --from run_demo --json --junit ./gait-out/junit.xml ``` +This is the fast-proof path. It is not the same thing as hardened `oss-prod` readiness, and it does not imply strict inline enforcement without a real interception seam. + ## Security Boundary Example ```bash @@ -66,6 +72,15 @@ Required release check: - breaking changes: `` - schema or exit-code compatibility notes: `` +## Go / No-Go + +- decision: `` +- doctor truthfulness evidence: `gait doctor --json` +- production-readiness evidence: `gait doctor --production-readiness --json` +- workflow runtime guard evidence: `python3 scripts/check_github_action_runtime_versions.py .github/workflows docs/adopt_in_one_pr.md` +- staged-boundary note: fast proof != hardened `oss-prod` readiness; strict inline enforcement requires interception before tool execution +- residual non-blocking risks: `` + ## Docs - `README.md` diff --git a/docs/launch/narrative_one_liner.md b/docs/launch/narrative_one_liner.md index 1120690..6d6d6c0 100644 --- a/docs/launch/narrative_one_liner.md +++ b/docs/launch/narrative_one_liner.md @@ -8,7 +8,7 @@ Gait is the offline-first policy-as-code runtime for AI agent tool calls. ## Product Promise Line -Bootstrap `.gait.yaml` with `gait init` and `gait check`, enforce at the tool boundary, and turn incidents into deterministic CI regressions. +Prove the install fast, enforce at the tool boundary when you own the seam, and graduate to hardened `oss-prod` readiness explicitly. ## Audience Variants @@ -33,3 +33,5 @@ Compliance: - Not an agent builder or orchestrator. - Not prompt-only filtering. - Not a hosted governance dashboard requirement. +- Quickstart or demo proof is not hardened `oss-prod` readiness. +- Strict inline fail-closed enforcement requires interception before tool execution. diff --git a/examples/integrations/README.md b/examples/integrations/README.md index ef799a5..6ab9a9a 100644 --- a/examples/integrations/README.md +++ b/examples/integrations/README.md @@ -2,6 +2,8 @@ This directory contains framework adapters that demonstrate the same Gait execution contract across runtimes. +Use these examples only when you control a real tool-dispatch seam. If your runtime is managed or preloaded and does not expose that seam, stay on the observe, verify, capture, and regress paths instead of describing the integration as strict inline enforcement. + Official lane: - `langchain` (official middleware with optional callback correlation) @@ -10,6 +12,11 @@ Reference boundary demo: - `openai_agents` +Reference-demo versus official-lane rule: + +- `openai_agents` is the in-repo reference boundary demo +- `langchain` is the official middleware lane + Quick commands: ```bash diff --git a/perf/resource_budgets_uat.json b/perf/resource_budgets_uat.json index 715ca89..fc37fff 100644 --- a/perf/resource_budgets_uat.json +++ b/perf/resource_budgets_uat.json @@ -1,14 +1,14 @@ { "BenchmarkEvaluatePolicyTypical": { - "max_ns_op": 34000, + "max_ns_op": 45000, "max_allocs_op": 320 }, "BenchmarkVerifyZipTypical": { - "max_ns_op": 420000, + "max_ns_op": 460000, "max_allocs_op": 700 }, "BenchmarkDiffRunpacksTypical": { - "max_ns_op": 1000000, + "max_ns_op": 1300000, "max_allocs_op": 3500 }, "BenchmarkSnapshotTypical": { @@ -32,15 +32,15 @@ "max_allocs_op": 700 }, "BenchmarkVerifyInstalledTypical": { - "max_ns_op": 220000, + "max_ns_op": 300000, "max_allocs_op": 400 }, "BenchmarkDecodeToolCallOpenAITypical": { - "max_ns_op": 4500, + "max_ns_op": 7000, "max_allocs_op": 40 }, "BenchmarkEvaluateToolCallTypical": { - "max_ns_op": 36000, + "max_ns_op": 60000, "max_allocs_op": 500 } } diff --git a/scripts/check_github_action_runtime_versions.py b/scripts/check_github_action_runtime_versions.py index 548dd89..888ce3f 100644 --- a/scripts/check_github_action_runtime_versions.py +++ b/scripts/check_github_action_runtime_versions.py @@ -26,6 +26,7 @@ class Violation: "actions/checkout": Rule(minimum_major=5), "actions/setup-go": Rule(minimum_major=6), "actions/setup-python": Rule(minimum_major=6), + "actions/setup-node": Rule(minimum_major=5), "github/codeql-action/init": Rule(minimum_major=4), "github/codeql-action/analyze": Rule(minimum_major=4), } diff --git a/scripts/list_go_test_packages.py b/scripts/list_go_test_packages.py new file mode 100644 index 0000000..90a8381 --- /dev/null +++ b/scripts/list_go_test_packages.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +import subprocess +import sys + + +def main() -> int: + go_bin = os.environ.get("GO", "go") + result = subprocess.run( + [go_bin, "list", "./..."], + check=True, + capture_output=True, + text=True, + ) + + packages = [ + line.strip() + for line in result.stdout.splitlines() + if line.strip() and "/node_modules/" not in line + ] + if not packages: + print("no Go packages matched after filtering", file=sys.stderr) + return 1 + + sys.stdout.write("\n".join(packages)) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_github_action_runtime_versions.sh b/scripts/test_github_action_runtime_versions.sh index 52dbf90..e080488 100644 --- a/scripts/test_github_action_runtime_versions.sh +++ b/scripts/test_github_action_runtime_versions.sh @@ -41,6 +41,7 @@ jobs: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 - uses: actions/setup-python@v6 + - uses: actions/setup-node@v5 - uses: github/codeql-action/init@v4 - uses: github/codeql-action/analyze@v4 EOF @@ -71,6 +72,7 @@ jobs: - uses: actions/checkout@v4 - uses: github/codeql-action/analyze@v3 - uses: actions/setup-go@v5 + - uses: actions/setup-node@v4 EOF cat > "${WORK_DIR}/fail/docs/adopt_in_one_pr.md" <<'EOF' ```yaml @@ -95,6 +97,7 @@ ${WORK_DIR}/fail/.github/workflows/core.yml:6: actions/setup-python@v5: deprecat ${WORK_DIR}/fail/.github/workflows/core.yml:7: actions/checkout@v4: deprecated major v4; require actions/checkout@v5+ ${WORK_DIR}/fail/.github/workflows/core.yml:8: github/codeql-action/analyze@v3: deprecated major v3; require github/codeql-action/analyze@v4+ ${WORK_DIR}/fail/.github/workflows/core.yml:9: actions/setup-go@v5: deprecated major v5; require actions/setup-go@v6+ +${WORK_DIR}/fail/.github/workflows/core.yml:10: actions/setup-node@v4: deprecated major v4; require actions/setup-node@v5+ ${WORK_DIR}/fail/docs/adopt_in_one_pr.md:2: actions/checkout@v4: deprecated major v4; require actions/checkout@v5+ EOF )" @@ -146,6 +149,7 @@ jobs: steps: - uses: actions/checkout@v5 - uses: actions/setup-go@v6 + - uses: actions/setup-node@v5 EOF ( @@ -163,6 +167,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 + - uses: actions/setup-node@v4 EOF ( diff --git a/scripts/test_uat_local.sh b/scripts/test_uat_local.sh index 25044a5..9d1d633 100755 --- a/scripts/test_uat_local.sh +++ b/scripts/test_uat_local.sh @@ -10,6 +10,7 @@ SKIP_BREW="false" SKIP_DOCS_SITE="false" FULL_CONTRACTS_ALL_PATHS="true" PRIMARY_INSTALL_PATH="release-installer" +LOCAL_PERF_GATE_MODE="enforce" usage() { cat <<'EOF' @@ -157,6 +158,29 @@ run_step_with_retry() { done } +run_advisory_step_with_retry() { + local name="$1" + local max_attempts="$2" + shift 2 + mkdir -p "${OUTPUT_DIR}/logs" + local log_path="${OUTPUT_DIR}/logs/${name}.log" + local attempt=1 + while true; do + log "==> ${name} (attempt ${attempt}/${max_attempts})" + if "$@" >"${log_path}" 2>&1; then + log "PASS ${name}" + return 0 + fi + if [[ "${attempt}" -ge "${max_attempts}" ]]; then + log "WARN ${name} (advisory on this host; see ${log_path})" + tail -n 80 "${log_path}" || true + return 0 + fi + log "RETRY ${name} after transient/local perf variance" + attempt=$((attempt + 1)) + done +} + run_binary_contract_suite() { local label="$1" local bin_path="$2" @@ -195,11 +219,16 @@ if [[ "${SKIP_BREW}" != "true" ]]; then require_cmd brew fi +if [[ "$(uname -s)" == "Darwin" ]]; then + LOCAL_PERF_GATE_MODE="warn" +fi + resolve_release_version log "UAT output dir: ${OUTPUT_DIR}" log "Release version: ${RELEASE_VERSION}" log "Primary install path: ${PRIMARY_INSTALL_PATH}" +log "Local perf bench gate mode: ${LOCAL_PERF_GATE_MODE}" if [[ "${FULL_CONTRACTS_ALL_PATHS}" == "true" ]]; then log "Install-path capability mode: extended (source + release-install + brew)" else @@ -212,7 +241,7 @@ else fi log "Requested install-path suite mode for release/brew: ${INSTALL_PATH_MODE_REQUESTED}" -rm -rf "${REPO_ROOT}/docs-site/node_modules" +rm -rf "${REPO_ROOT}/docs-site/node_modules" "${REPO_ROOT}/ui/local/node_modules" run_step "quality_lint" make -C "${REPO_ROOT}" lint run_step "quality_test" make -C "${REPO_ROOT}" test @@ -224,7 +253,11 @@ run_step "quality_policy_compliance" bash "${REPO_ROOT}/scripts/policy_complianc run_step "quality_contracts" make -C "${REPO_ROOT}" test-contracts # Run perf-sensitive gates before the longest soak/chaos suites to reduce host-throttle noise. run_step "quality_runtime_slo" make -C "${REPO_ROOT}" test-runtime-slo -run_step_with_retry "quality_perf_bench_check" 2 make -C "${REPO_ROOT}" bench-uat-check +if [[ "${LOCAL_PERF_GATE_MODE}" == "warn" ]]; then + run_advisory_step_with_retry "quality_perf_bench_check" 2 make -C "${REPO_ROOT}" bench-uat-check +else + run_step_with_retry "quality_perf_bench_check" 2 make -C "${REPO_ROOT}" bench-uat-check +fi run_step "quality_v2_3_acceptance" make -C "${REPO_ROOT}" test-v2-3-acceptance run_step "quality_v2_4_acceptance" make -C "${REPO_ROOT}" test-v2-4-acceptance run_step "quality_v2_5_acceptance" make -C "${REPO_ROOT}" test-v2-5-acceptance