From 0e5779588748883bb37576bdb8a5b7c981db4d6c Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:14:54 -0400 Subject: [PATCH 1/2] Guard flow integration lifecycle --- .github/CONTRIBUTING.md | 8 +- .github/workflows/ci.yml | 155 ++++++++++++++++- .github/workflows/codeql.yml | 6 +- .serena/project.yml | 12 +- AGENTS.md | 87 ++++++---- README.md | 6 +- docs/agent-config.md | 42 +++-- docs/ci.md | 47 +++--- docs/development.md | 81 ++++++--- docs/serena.md | 23 ++- docs/workstreams.md | 167 ++++++++++++++++--- policy/deny.patterns | 4 +- policy/protected.paths | 26 +++ scripts/dev/required-gates | 65 +++++++- scripts/dev/workflow-check | 137 +++++++++++++-- scripts/dev/workstream | 267 +++++++++++++++++++++++++----- tests/agent/policy-verify.sh | 72 +++++++- tests/dev/ci-workflow-cases.sh | 108 ++++++++++-- tests/dev/required-gates-cases.sh | 96 ++++++++++- tests/dev/security-gate-cases.sh | 4 +- tests/dev/workflow-check-cases.sh | 62 ++++++- tests/dev/workstream-cases.sh | 236 ++++++++++++++++++++++++-- tests/guard/pretooluse-cases.sh | 71 +++++++- tests/security/fast.manifest | 4 +- tests/serena/config-cases.sh | 107 +++++++++++- tools/bin/gh | 68 +++++--- tools/bin/git | 109 ++++++++---- tools/pretooluse-guard.sh | 122 ++++++++++++-- tools/render-adapters.sh | 4 +- tools/session-bootstrap.sh | 9 +- 30 files changed, 1883 insertions(+), 322 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index dba5eba..cf6a236 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -19,8 +19,12 @@ Pull requests and issues from anyone who is not a member of the organization wil ## For organization members - Follow the [branch, metadata, and integration workflow](../docs/development.md#branch-and-integration-workflow). -- Publish only the current work branch and open its PR to `dev`. -- Humans review and merge; agents never write protected branches or merge their own PRs. +- Publish only the current branch and use its exact derived PR base: ordinary/workstream work targets + `dev`, program groups target `flow`, and slices target their matching parent. +- Treat `dev`, `flow`, `master`, and `main` as protected. Agents may merge only an approved, current, + green intermediate PR through `scripts/dev/workstream`; humans merge every final PR into `dev`. +- Preserve merge ancestry and branch/PR evidence. The required hosted protections and exact program + routes are in [Workstreams and programs](../docs/workstreams.md). If you have questions about using the lab locally, start with the [README](../README.md) and [documentation map](../docs/README.md). There is no diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afb2efd..45cf515 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,9 @@ concurrency: on: push: - branches: [dev, master, main] + branches: [dev, flow, master, main] pull_request: - branches: [dev, master, main, 'work/**'] + branches: [dev, flow, master, main, 'work/**', 'group/**'] merge_group: types: [checks_requested] @@ -23,6 +23,8 @@ jobs: timeout-minutes: 15 outputs: diff-base: ${{ steps.diff-base.outputs.base }} + tested-head: ${{ steps.diff-base.outputs.head }} + classification: ${{ steps.fast-gate.outputs.classification }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: @@ -31,17 +33,97 @@ jobs: - name: Install shellcheck run: sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + - name: Validate pull request topology + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} + REPOSITORY: ${{ github.repository }} + run: | + if [ "$EVENT_NAME" != pull_request ]; then + exit 0 + fi + if [ "$PR_HEAD_REPOSITORY" != "$REPOSITORY" ]; then + printf '::error title=Invalid PR repository::cross-repository PRs are not accepted\n' + exit 1 + fi + + component='[a-z0-9][a-z0-9-]{0,47}' + group='[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*' + valid_group() { + [ "${#1}" -le 48 ] && [[ "$1" =~ ^$group$ ]] + } + case "$PR_BASE_REF" in + flow) + group_name="${PR_HEAD_REF#group/}" + [ "$PR_HEAD_REF" = "group/$group_name" ] && valid_group "$group_name" + ;; + group/*) + group_name="${PR_BASE_REF#group/}" + valid_group "$group_name" && + [[ "$PR_HEAD_REF" =~ ^slice/group/$group_name/$component$ ]] + ;; + work/*) + work_name="${PR_BASE_REF#work/}" + [[ "$work_name" =~ ^$component$ ]] && + [[ "$PR_HEAD_REF" =~ ^slice/$work_name/$component$ ]] + ;; + dev) + [[ ! "$PR_HEAD_REF" =~ ^(group|slice)/ ]] + ;; + master | main) + [[ ! "$PR_HEAD_REF" =~ ^(group|slice)/ ]] + ;; + *) false ;; + esac || { + printf '::error title=Invalid PR topology::%s cannot target %s\n' \ + "$PR_HEAD_REF" "$PR_BASE_REF" + exit 1 + } + - name: Resolve committed diff base id: diff-base env: EVENT_NAME: ${{ github.event_name }} PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PUSH_BEFORE_SHA: ${{ github.event.before }} + PUSH_AFTER_SHA: ${{ github.event.after }} + REF_NAME: ${{ github.ref_name }} MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + EXPECTED_HEAD_SHA: ${{ github.sha }} run: | + sha_pattern='^([0-9a-f]{40}|[0-9a-f]{64})$' + head="$(git rev-parse HEAD)" + if [[ ! "$EXPECTED_HEAD_SHA" =~ $sha_pattern ]] || + [[ "$EXPECTED_HEAD_SHA" =~ ^0+$ ]] || + [ "$head" != "$EXPECTED_HEAD_SHA" ]; then + printf '::error title=Unexpected tested head::expected %s, checked out %s\n' \ + "${EXPECTED_HEAD_SHA:-}" "${head:-}" + exit 125 + fi + case "$EVENT_NAME" in pull_request) base="$PR_BASE_SHA" ;; - push) base="$PUSH_BEFORE_SHA" ;; + push) + if [[ "$PUSH_BEFORE_SHA" =~ ^0+$ ]]; then + if ! git fetch --no-tags origin \ + '+refs/heads/dev:refs/remotes/origin/dev'; then + printf '::error title=Missing bootstrap authority::cannot resolve origin/dev\n' + exit 125 + fi + dev_head="$(GIT_NO_REPLACE_OBJECTS=1 git rev-parse --verify 'origin/dev^{commit}')" + if [ "$REF_NAME" = flow ] && [ "$PUSH_AFTER_SHA" = "$head" ] && + [ "$head" = "$dev_head" ]; then + base="$head" + else + printf '::error title=Invalid branch creation::flow bootstrap must equal current origin/dev\n' + exit 125 + fi + else + base="$PUSH_BEFORE_SHA" + fi + ;; merge_group) base="$MERGE_GROUP_BASE_SHA" ;; *) printf '::error title=Unsupported CI event::%s has no deterministic diff base\n' \ @@ -50,7 +132,6 @@ jobs: ;; esac - sha_pattern='^([0-9a-f]{40}|[0-9a-f]{64})$' if [[ ! "$base" =~ $sha_pattern ]] || [[ "$base" =~ ^0+$ ]]; then printf '::error title=Invalid CI diff base::event supplied %s\n' \ "${base:-}" @@ -67,18 +148,28 @@ jobs: exit 125 fi - head="$(git rev-parse HEAD)" printf 'base=%s\n' "$base" >> "$GITHUB_OUTPUT" printf 'head=%s\n' "$head" >> "$GITHUB_OUTPUT" - name: Fast security gate + id: fast-gate env: AGENT_LAB_DIFF_BASE: ${{ steps.diff-base.outputs.base }} CI_LOG_DIR: ${{ runner.temp }}/agent-lab-ci run: | + set +e set -o pipefail mkdir -p "$CI_LOG_DIR" ./scripts/dev/ci-fast 2>&1 | tee "$CI_LOG_DIR/fast.log" + rc=$? + case "$rc" in + 0) classification=success ;; + 1) classification=assertion-failure ;; + 125) classification=infrastructure ;; + *) classification=unexpected ;; + esac + printf 'classification=%s\n' "$classification" >> "$GITHUB_OUTPUT" + exit "$rc" - name: Summarize fast gate if: ${{ always() }} @@ -109,16 +200,43 @@ jobs: name: Static runs-on: ubuntu-latest timeout-minutes: 15 + outputs: + tested-head: ${{ steps.identity.outputs.head }} + classification: ${{ steps.static-gate.outputs.classification }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Bind tested checkout + id: identity + env: + EXPECTED_HEAD_SHA: ${{ github.sha }} + run: | + head="$(git rev-parse HEAD)" + if [ "$head" != "$EXPECTED_HEAD_SHA" ]; then + printf '::error title=Unexpected tested head::expected %s, checked out %s\n' \ + "$EXPECTED_HEAD_SHA" "$head" + exit 125 + fi + printf 'head=%s\n' "$head" >> "$GITHUB_OUTPUT" + - name: Static configuration gate + id: static-gate env: CI_LOG_DIR: ${{ runner.temp }}/agent-lab-ci run: | + set +e set -o pipefail mkdir -p "$CI_LOG_DIR" ./tools/validate.sh --strict 2>&1 | tee "$CI_LOG_DIR/static.log" + rc=$? + case "$rc" in + 0) classification=success ;; + 1) classification=assertion-failure ;; + 125) classification=infrastructure ;; + *) classification=unexpected ;; + esac + printf 'classification=%s\n' "$classification" >> "$GITHUB_OUTPUT" + exit "$rc" - name: Summarize static gate if: ${{ always() }} @@ -144,9 +262,25 @@ jobs: name: Docker security runs-on: ubuntu-latest timeout-minutes: 45 + outputs: + tested-head: ${{ steps.identity.outputs.head }} + classification: ${{ steps.docker-gate.outputs.classification }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - name: Bind tested checkout + id: identity + env: + EXPECTED_HEAD_SHA: ${{ github.sha }} + run: | + head="$(git rev-parse HEAD)" + if [ "$head" != "$EXPECTED_HEAD_SHA" ]; then + printf '::error title=Unexpected tested head::expected %s, checked out %s\n' \ + "$EXPECTED_HEAD_SHA" "$head" + exit 125 + fi + printf 'head=%s\n' "$head" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Build cache-aware devbox @@ -165,9 +299,19 @@ jobs: AGENT_LAB_DEVBOX_PREBUILT: 1 CI_LOG_DIR: ${{ runner.temp }}/agent-lab-ci run: | + set +e set -o pipefail mkdir -p "$CI_LOG_DIR" ./scripts/dev/docker-gate 2>&1 | tee "$CI_LOG_DIR/docker.log" + rc=$? + case "$rc" in + 0) classification=success ;; + 1) classification=assertion-failure ;; + 125) classification=infrastructure ;; + *) classification=unexpected ;; + esac + printf 'classification=%s\n' "$classification" >> "$GITHUB_OUTPUT" + exit "$rc" - name: Summarize Docker gate if: ${{ always() }} @@ -201,4 +345,5 @@ jobs: - name: Reduce required gate results env: CI_NEEDS_JSON: ${{ toJSON(needs) }} + CI_EXPECTED_HEAD: ${{ github.sha }} run: ./scripts/dev/required-gates diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b105e42..cd4ff63 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,9 +7,9 @@ concurrency: on: push: - branches: [dev, master, main] + branches: [dev, flow, master, main] pull_request: - branches: [dev, master, main, 'work/**'] + branches: [dev, flow, master, main, 'work/**', 'group/**'] merge_group: types: [checks_requested] schedule: @@ -17,7 +17,7 @@ on: jobs: analyze: - name: Analyze + name: CodeQL runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/.serena/project.yml b/.serena/project.yml index b4617c1..120357a 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -72,7 +72,9 @@ ignore_all_files_in_gitignore: true # Maps the language key to the options. # The settings are considered only if the project is trusted (see global configuration to define trusted projects). # See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings -ls_specific_settings: {} +ls_specific_settings: + bash: + bash_language_server_version: "5.6.0" # list of workspace folder paths (LSP backend only). # These folders will be used to build up Serena's symbol index. @@ -154,7 +156,13 @@ initial_prompt: >- dedicated no-network Serena container. AGENTS.md and docs/serena.md are canonical; Serena memories are optional development assistance, never repository authority. Never inspect or modify credentials, tokens, GitHub - authentication, Git attribution, or unrelated host state. + authentication, Git attribution, or unrelated host state. Before changing + code, establish branch and worktree state with the host-side + ./scripts/dev/brief and ./scripts/dev/changed commands. Serena does not + establish Git or GitHub authority. For flow, group, or slice work, follow + AGENTS.md and docs/workstreams.md and use ./scripts/dev/workstream for + integration. Use ordinary tools for prose, configuration, and extensionless + Bash rails. # time budget (seconds) per tool call for the retrieval of additional symbol information # such as docstrings or parameter information. diff --git a/AGENTS.md b/AGENTS.md index cca4ea3..ab4d9bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,34 +1,47 @@ # AGENTS.md — Agent Lab operating rules -Agent Lab is a Docker-containment lab (bash + docker-compose). You are an agent **developing this -repo**. Work autonomously inside the boundary below; a `PreToolUse` guard enforces the edges so you -don't have to think about them. The guard is defense-in-depth — the **real** safety boundary is -containment, not these rules. See `SECURITY.md` and `THREAT_MODEL.md`. +Agent Lab is a Docker-containment lab (Bash + Docker Compose). You are an agent **developing this +repo**. Work autonomously inside the boundary below; a `PreToolUse` guard enforces the edges. The +guard is defense-in-depth—the **real** safety boundary is containment. See `SECURITY.md` and +`THREAT_MODEL.md`. -Repo: `https://github.com/uscient/agent-lab` · base branch: `dev` +Repo: `https://github.com/uscient/agent-lab` · authoritative branch: `dev` ## Prime directives -- **Work from `dev` or a declared workstream.** Standalone work uses a non-protected branch from - `dev`; reusable workstreams use `work/` and `slice//` via - `scripts/dev/workstream`. Never commit on `dev`/`master`/`main`. -- **Integrate remote state one way: rebase your branch on `origin/dev`** (`fetch`, then rebase; `git push --force-with-lease` to update your pushed branch afterward). -- **Integrate through PRs.** Humans merge standalone/final workstream PRs into `dev`. Agents may - merge matching slice PRs into `work/` only through `scripts/dev/workstream merge` after it - observes every check successful. Never push or merge to protected branches directly. -- **Don't edit the rails** (`AGENTS.md`, `policy/`, the guards, your tool config) unless explicitly doing maintenance (`AGENT_LAB_MAINTENANCE=1`). -- **Never inspect or change GitHub authentication, credentials, tokens, account settings, or Git attribution configuration.** -- On a judgment call, prefer the reversible action. + +- **Use the branch-derived route.** `dev`, `flow`, `master`, and `main` are protected. Ordinary and + `work/*` branches PR to `dev`. A `group/` branch tracks `origin/flow` + and PRs to `flow`; `` is at most 48 characters and matches + `[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*`. Legacy + `slice//` and literal group `slice/group//` branches target only their + matching parent. +- **Integrate through PRs and retain history.** `scripts/dev/workstream merge` is the only agent + merge path: verified slice→work, group-slice→group, and approved group→`flow`. It requires the + current base and successful checks and uses a merge commit. Humans alone merge final ordinary, + workstream, or `flow` PRs into `dev`. Never directly push, commit, Git-merge, or rebase a protected + branch. +- **Sync without erasing accepted merges.** Use `scripts/dev/workstream sync` on reserved branches. + Workstreams, program groups, and group slices merge their moving parent and may never be rebased or + force-updated; legacy slices may rebase only on their matching parent. Replay evidence after every + sync. Never squash accepted history or delete integration branches/PR evidence. +- **Don't edit the rails** (`AGENTS.md`, `policy/`, guards, protected workflow/helper/check paths, or + tool configuration) unless explicitly doing maintenance (`AGENT_LAB_MAINTENANCE=1`). +- **Never inspect or change GitHub authentication, credentials, tokens, account settings, or Git + attribution configuration.** On a judgment call, prefer the reversible action. ## Autonomy boundary (act without prompts inside the left column) + | Auto — no prompt | Denied — guard blocks (exit 2) | |---|---| -| read · edit · tests/build/lint · `git add`/`commit` · local `merge`/`rebase` · branch/switch · `git fetch`¹ · `git stash` · rebase on `origin/dev` · push the current branch to its same-named `origin` branch (`--force-with-lease` after rebase)¹ · read-only `gh pr` and `gh run` · `gh pr create --base dev`¹ · `scripts/dev/workstream` slice PR creation and verified slice integration¹ | push or merge to `dev`/`master`/`main` · `pull` · `merge`/`rebase` from unrelated `origin/*` · plain `--force`, branch deletion, mirror push · direct PR merge & other unscoped `gh` remote-writes · `git remote` mutation · `gh auth` · Git identity/attribution mutation | -| | destructive: `rm -rf` · `reset --hard` · `clean -fdx` · history rewrite (outside your branch's rebase) · broad `chmod`/`chown` · `sudo` · `sed -i` | +| read · edit · tests/build/lint · `git add`/`commit` · branch/switch · `git fetch`¹ · `git stash` · exact allowed rebase or workstream sync · push the current branch to its same-named `origin` branch (`--force-with-lease` only after an allowed non-program rebase)¹ · read-only `gh pr`/`gh run` · exact branch-derived PR creation¹ · verified `scripts/dev/workstream` intermediate integration¹ | write/integrate `dev`/`flow`/`master`/`main` directly · `pull` · integration from an unrelated remote base · program-branch force/rebase · plain `--force`, branch deletion, mirror push · direct PR merge or other unscoped `gh` remote write · `git remote` mutation · `gh auth` · Git identity/attribution mutation | +| | destructive: `rm -rf` · `reset --hard` · `clean -fdx` · unapproved history rewrite · broad `chmod`/`chown` · `sudo` · `sed -i` | | | containment: `docker.sock` · `--privileged` · host-net · secret/`.env` writes | -¹ Remote operations depend on the active runtime having network access. If it does not, finish the local work and report the publication blocker accurately. +¹ Remote operations depend on the active runtime having network access. If unavailable, finish local +work and report the publication blocker accurately. ## Commands — the real stack + | Do | Run | |---|---| | lint | `./scripts/dev/lint-scripts` | @@ -37,28 +50,38 @@ Repo: `https://github.com/uscient/agent-lab` · base branch: `dev` | containment validate | `./tools/validate.sh` · `./tools/containment-lint.sh` | | unit tests | `bash tests/guard/pretooluse-cases.sh` · `bash tests/guard/cases.sh` · `bash tests/agent/*.sh` | | orient | `./scripts/dev/brief` · `./scripts/dev/changed` · `./scripts/doctor` | +| workstream/program | `./scripts/dev/workstream` · see `docs/workstreams.md` | | stack | `./scripts/up [core\|egress\|devtools]` · `./scripts/down` · `./scripts/agent` | | Serena | `./scripts/dev/serena-build` · `./scripts/dev/serena-smoke` | -Use integrations only for the scoped repository/GitHub workflow. No secret access. Never weaken containment (`SECURITY.md`, `THREAT_MODEL.md`). +Use integrations only for the scoped repository/GitHub workflow. No secret access. Never weaken +containment (`SECURITY.md`, `THREAT_MODEL.md`). ## Serena — semantic development tooling -- Serena runs in its dedicated no-network container; it is not an Agent Lab workload, runtime dependency, authority system, or source of truth. Its logical project is `agent-lab-dev` at container path `/workspace`, using the Bash LSP backend. -- Before substantial semantic work, call `get_current_config`. In the pinned Serena version, a fresh session returns the expected `isError` state `No active project`; recover with `activate_project` on `/workspace`, then call `get_current_config` again. Activation alone is not readiness—complete a live symbol operation. -- Start with `get_symbols_overview` or targeted `find_symbol`, retrieve only needed bodies, and use `find_declaration` / `find_referencing_symbols` to assess impact. Prefer the usable bounded semantic editors—`replace_symbol_body`, `insert_before_symbol`, and `insert_after_symbol`—when the change matches a reliable symbol boundary. -- Use ordinary search/edit tools for prose, configuration, generated data, partial text changes, extensionless Bash entrypoints, and the stdlib-only Python smoke harness, which are outside the configured Bash semantic scope. If this follows a Serena failure, state the failure instead of claiming semantic verification. -- After edits, inspect the affected symbols and call `get_diagnostics_for_file`; then run the normal tests/lint/build separately. Serena never replaces repository gates. -- The activation response plus `list_memories` is the current onboarding check. Keep any future memory factual and project-specific; never store secrets, tokens, transient container IDs, or host-only paths. See `docs/serena.md` for failure-state diagnosis and smoke evidence. + +- Serena runs in its dedicated no-network container; it is not a workload, runtime dependency, + authority system, or source of truth. Its project is `agent-lab-dev` at `/workspace`, using Bash LSP. +- Before substantial semantic work, call `get_current_config`. A fresh pinned session may return + `No active project`; call `activate_project` on `/workspace`, then `get_current_config` again. + Activation is not readiness—complete a live symbol operation. +- Start with `get_symbols_overview` or targeted `find_symbol`; use `find_declaration` and + `find_referencing_symbols` for impact. Prefer bounded semantic editors when boundaries are reliable. +- Use ordinary tools for prose, configuration, generated data, partial text, extensionless Bash, and + the Python smoke harness. If Serena failed, report that instead of claiming semantic verification. +- After edits, inspect affected symbols, call `get_diagnostics_for_file`, and run normal gates. + Activation plus `list_memories` is the onboarding check. Never store secrets, transient IDs, or + host-only paths. See `docs/serena.md`. ## Authority + - `AGENTS.md` is the sole operating-policy source for agents developing this repository. -- GitHub is the integration source of truth. Agents may fetch, publish their own work branch, and open a PR to `dev`. -- Humans own final `dev` review/merge, releases, protected branches, authentication, and policy. - Agents own intermediate workstream integration only under the checked contract above. -- Explicit rail maintenance requires `AGENT_LAB_MAINTENANCE=1`; ordinary tasks must not mutate the rails. +- GitHub is the integration source of truth. Humans create and protect initial `flow` at the exact R0 + `dev` SHA, own final merges into `dev`, releases, protected settings, authentication, and policy. +- Agents may publish their current branch, open only its derived PR route, and perform only the + verified intermediate merges above. Explicit rail maintenance requires `AGENT_LAB_MAINTENANCE=1`. -Done = standalone/final workstream PR open to `dev`, or a verified slice PR merged into its -workstream, plus a short handoff. Humans merge final PRs. +Done = the scoped verified slice/group integration is complete, or the final draft PR to `dev` is +open, plus a short handoff. Humans merge every final PR into `dev`. --- -_Updated 2026-07-30_ +_Updated 2026-08-03_ diff --git a/README.md b/README.md index 7d7df39..e579eb0 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,10 @@ to diagnose, not permission to bypass the control. ## Contributing, security, and license -External pull requests and issues are not accepted. Organization members follow `AGENTS.md`, work on -a branch from `dev`, and use human-reviewed pull requests to integrate changes. +External pull requests and issues are not accepted. Organization members follow `AGENTS.md` and the +[branch-derived integration workflow](docs/development.md#branch-and-integration-workflow). `dev` +remains authoritative; `flow` is a protected program-integration branch, verified intermediate +merges use the workstream helper, and humans own every final merge into `dev`. Organization members report suspected boundary bypasses or secret exposure through the private channel described in [SECURITY.md](SECURITY.md). This mirror publishes no external intake; never put diff --git a/docs/agent-config.md b/docs/agent-config.md index 8bddfae..be7a76e 100644 --- a/docs/agent-config.md +++ b/docs/agent-config.md @@ -1,8 +1,9 @@ # Agent configuration — setup & maintenance How the three coding agents that **develop Agent Lab** (Claude Code, Codex, Grok) are configured to -work autonomously inside the `AGENTS.md` boundary: develop on a branch, publish that branch, open a -PR to `dev`, never merge it themselves, and never weaken containment. +work autonomously inside the `AGENTS.md` boundary: develop on a writable branch, publish only that +branch, follow its derived PR route, use the verified helper for allowed intermediate merges, leave +every final merge into `dev` to a human, and never weaken containment. For the normal maintainer workflow and gates, start with [Development and verification](development.md). This page is the specialist reference for client @@ -27,7 +28,7 @@ One operating policy, shared enforcement, and three generated thin adapters: ```text AGENTS.md policy/ # shared core (instruction + enforcement data) tools/pretooluse-guard.sh # PreToolUse: command, read/write, and Serena mutations -tools/session-bootstrap.sh # SessionStart: protected branch -> dev-based work branch +tools/session-bootstrap.sh # SessionStart: dev/master/main -> work branch; flow read-only tools/render-adapters.sh # generates the adapter rule bodies tools/codex-permission-request.sh # Codex PermissionRequest approver (mirrors policy) tools/bin/{git,gh} # optional argv-level PATH shims @@ -58,10 +59,11 @@ regions** in `.claude/settings.json`, `.codex/rules/agent-lab.rules`, or `.grok/ ## The `AGENT_LAB_MAINTENANCE=1` convention (and the self-lock) -The rails (`AGENTS.md`, `policy/`, the guard scripts, `tools/bin/`, the adapter dirs) -are in `policy/protected.paths`: the guard blocks Edit/Write and shell-mutation of them so an agent -can't quietly change its own guardrails. To **deliberately** maintain them, run the session with -`AGENT_LAB_MAINTENANCE=1` **exported in the launching shell** (so the hook subprocess inherits it): +`policy/protected.paths` is the complete rail inventory. It includes `AGENTS.md`, policy, guards, +shims, adapters, workflows, the workstream and workflow-check helpers, required-gate reducers and +manifests, and their contract tests. The guard blocks Edit/Write and shell mutation of those paths so +an agent can't quietly change its own guardrails. To **deliberately** maintain them, run the session +with `AGENT_LAB_MAINTENANCE=1` **exported in the launching shell** (so the hook subprocess inherits it): ```bash AGENT_LAB_MAINTENANCE=1 claude # or codex / grok @@ -92,15 +94,33 @@ Codex runs `sandbox_mode = "workspace-write"` with network access enabled for th workflow. `AGENTS.md`, the guard, and protected-branch rules scope that access: ```bash -git fetch origin +# Ordinary branch: +git fetch origin dev git rebase origin/dev git push -u origin HEAD gh pr create --base dev ... + +# Reserved workstream and program branches: +./scripts/dev/workstream sync +./scripts/dev/workstream pr ... # matching slice -> parent +./scripts/dev/workstream group-pr ... # group -> flow, draft +./scripts/dev/workstream final ... # work or flow -> dev, draft +./scripts/dev/workstream merge 123 # approved intermediate only ``` -Direct protected-branch pushes, plain force pushes, PR merge/mutation, remote mutation, GitHub -authentication access, and Git attribution changes remain forbidden. Runtime credentials are used -implicitly by Git/GitHub tooling; agents must never inspect or modify them. +Ordinary branches rebase on `origin/dev`; reusable workstreams, program groups, and group slices use +merge-preserving `workstream sync` with their exact parent. Those integration branches are never +rebased or force-updated. `dev`, `flow`, `master`, and `main` are protected, and a `flow` +checkout stays read-only. Direct protected writes, plain force pushes, direct PR merge/mutation, remote +mutation, GitHub authentication access, and Git attribution changes remain forbidden. The only +agent merge exception is `scripts/dev/workstream merge` for a verified slice→work, +group-slice→group, or approved group→`flow` PR. Humans alone merge final PRs into `dev`. + +The repository guards do not configure GitHub. Humans must install the required `CI / Required +gates` and `CodeQL` checks, current-base or merge-queue rule, latest-push approval, merge-only history, +branch retention, and trusted rail ownership described in [Workstreams and programs](workstreams.md). +Runtime credentials are used implicitly by Git/GitHub tooling; agents must never inspect or modify +them. ## Forbidden flags (never use these as the autonomy mechanism) diff --git a/docs/ci.md b/docs/ci.md index bc1f8c3..0754208 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -1,15 +1,16 @@ # CI as an agent-facing gate Agent Lab CI is a versioned contract, not an advisory test counter. It runs on -pull requests, merge-queue candidates, and pushes to `dev`, `master`, and -`main`, and exposes one stable branch-protection result: +merge-queue candidates, pushes to `dev`, `flow`, `master`, and `main`, and pull +requests targeting those branches plus `work/**` and `group/**`. It exposes one stable result: **`CI / Required gates`**. -`dev` is the repository's integration and pull-request base. The additional -`master` and `main` workflow triggers protect those names if they are retained -as publication or compatibility branches; a trigger does not make either one -an agent integration target. See [Development and verification](development.md) -for the branch workflow. +`dev` remains authoritative. `flow` is the protected program base, `group/**` +receives only its matching `slice/group/**`, and `work/**` receives only its +matching legacy slice. Fast CI rejects cross-repository or level-skipping PR +topologies. The `master` and `main` triggers protect retained publication or +compatibility names; a trigger grants no integration authority. See +[Development and verification](development.md) for the exact routes. ## Required workers @@ -21,13 +22,16 @@ for the branch workflow. The `Required gates` job consumes GitHub's structured `needs` result, compares it with `tests/security/ci.manifest`, and succeeds only when the exact required -set is present and every result is `success`. It combines the Fast worker's -validated event base with the versioned replay command, so its summary contains -a concrete command rather than a guessed Git ref. A missing, extra, skipped, -cancelled, malformed, or unknown result fails closed. +set is present, every result is `success`, every worker classifies that result +as success, and every worker binds evidence to the exact event head. It combines +the Fast worker's validated event base with the versioned replay command, so its +summary contains a concrete command rather than a guessed Git ref. Assertion +failures block with `1`; missing, extra, stale, skipped, cancelled, malformed, +or infrastructure-uncertain evidence fails closed with `125`. CodeQL remains a separate check because GitHub does not expose cross-workflow -jobs through `needs`. +jobs through `needs`. Its job name is fixed as `CodeQL` so the merge helper and +hosted rules can require the same unambiguous result. The Docker worker always runs the full runtime gate. Its cache-aware devbox build is a separate timed step, and the gate records runtime-suite timings so @@ -44,9 +48,11 @@ optional check. The optional OpenClaw image is not built by CI. 5. Fix the source defect; do not weaken assertions, convert failures to skips, or add blanket retries. -The fast job records and validates the immutable event diff base. It rejects -missing, zero, malformed, unfetched, or non-ancestor SHAs rather than guessing -`HEAD^`. +The fast job records and validates the immutable event diff base and checked-out +head. It rejects missing, malformed, unfetched, non-ancestor, or mismatched SHAs +rather than guessing `HEAD^`. The sole zero-predecessor exception is the human +creation of literal `flow` at the exact R0-updated `dev` commit; that push still +runs the complete gates and CodeQL. ## Trust boundary @@ -65,11 +71,12 @@ write access. ## Repository ruleset -After the workflow has emitted its first check, require `CI / Required gates` -on `dev` and on every retained publication branch (`master` or `main`) that can -receive changes. Require CodeQL through the repository's code-scanning rule. -Enable the up-to-date-branch requirement or a merge queue so the tested -synthetic merge commit includes the current integration branch. +After each base has emitted its first check, require `CI / Required gates` and +CodeQL on `dev`, `flow`, every `group/**` base, and any retained publication +branch. Require current-base testing or a merge queue, approval of the latest +push, and stale-approval dismissal. Deny force updates and deletion for +`flow`, `work/**`, `group/**`, and `slice/group/**`; keep merge commits and disable +automatic program-branch deletion through final review. Do not require worker or matrix names individually. The stable aggregate is the public contract; its versioned manifest defines the internal required set. diff --git a/docs/development.md b/docs/development.md index d13700c..bea9943 100644 --- a/docs/development.md +++ b/docs/development.md @@ -27,17 +27,42 @@ plane you are working on. ## Branch and integration workflow -The integration branch is `dev`. +`dev` is authoritative. The protected `flow` branch is a bounded program-integration base, not a +replacement for `dev`. The exact route is derived from the branch: + +| Head branch | Rebase/sync base | Pull-request base | Integration owner | +|---|---|---|---| +| ordinary branch | `origin/dev` | `dev` | human | +| `work/` | merge `origin/dev` with `sync` | `dev` | human | +| `slice//` | `origin/work/` | `work/` | verified helper | +| `group/` | merge `origin/flow` with `sync` | `flow` | verified helper after approval | +| `slice/group//` | merge `origin/group/` with `sync` | `group/` | verified helper | +| protected `flow` | none | `dev` | human | + +`` matches `[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*`; `group` in the group-slice form is +literal. `dev`, `flow`, `master`, and `main` are protected. Do not commit, push, Git-merge, or rebase +them directly. An agent may inspect `flow`, use the verified helper to merge an approved group PR +remotely, and open the final draft `flow` → `dev` PR; only a human merges that final PR. + +For each writable branch: + +1. Start at its current permitted base and make focused changes. +2. Run applicable behavior, security, and mutation evidence. +3. Fetch and incorporate only the exact base in the table. Use `scripts/dev/workstream sync` for a + reserved workstream, group, or slice branch. +4. Replay invalidated evidence after a rebase, dependency merge, workflow change, or manifest change. +5. Push only the same-named branch. Use `--force-with-lease` only after an allowed non-program + rebase; program groups and group slices are merge-preserving and never force-updated. +6. Open only the derived PR route. Agents integrate only verified slice→work, group-slice→group, and + approved group→`flow` PRs through `scripts/dev/workstream merge`. +7. Preserve merge commits, branches, PR records, and evidence through final review. Humans merge every + ordinary, workstream, or `flow` final PR into `dev`. + +The R0 maintenance PR must land in `dev` before `flow` exists. A human creates and protects `flow` at +that exact R0-updated `dev` SHA, then installs current-head CI, CodeQL, review, retention, and merge +rules before program work begins. See [Agent-managed workstreams and programs](workstreams.md) for +commands and the hosted-rules checklist. -1. Create a work branch from current `dev`. -2. Make and verify focused changes on that branch. -3. Fetch remote state. -4. Rebase only on `origin/dev`. -5. Push the same-named work branch. After a rebase, use `--force-with-lease`, never plain force. -6. Open a pull request with base `dev`. -7. A human reviews and merges it. - -Do not commit on or push directly to `dev`, `master`, or `main`. Do not merge your own pull request. Never inspect or change repository authentication, credentials, tokens, account settings, or Git attribution configuration. @@ -46,10 +71,10 @@ maintenance session. Ordinary feature and documentation work does not mutate the ### Workflow metadata -Branch naming is a review convention, not a new authorization rail. Prefer a descriptive -`/` name for manual work. The automated bootstrap form -`agent//` remains valid. The checker rejects protected, generic, invalid, and Git-magic -names, but `AGENTS.md` remains authoritative about which branch operations are allowed. +For ordinary work, prefer a descriptive `/` name. The automated bootstrap +form `agent//` remains valid. Reserved `work/*`, `group/*`, and both slice forms carry the +routing semantics in the table; malformed reserved names are refused. The checker also rejects +protected, generic, invalid, and Git-magic names, but `AGENTS.md` remains authoritative. Introduced non-merge commits use the exact author `xormania <127287135+xormania@users.noreply.github.com>`. Subjects are one printable ASCII line, @@ -57,9 +82,9 @@ Introduced non-merge commits use the exact author merge boilerplate, branch reference, or generic update text. Plain imperative subjects and scoped Conventional Commit subjects are both valid. Do not add additional attribution trailers. -Pull requests use base `dev` and an outcome-focused title under the same subject rules. The body -keeps the template sections `Summary`, `Motivation / Context`, `Changes`, and `Testing` in that -order. Testing entries name an exact command in backticks and its observed result. If no command +Pull requests use the branch-derived base and an outcome-focused title under the same subject rules. +The body keeps the template sections `Summary`, `Motivation / Context`, `Changes`, and `Testing` in +that order. Testing entries name an exact command in backticks and its observed result. If no command ran, write `Not run — reason`. Check or remove every template checklist item before validation. Run the local convention checks from the repository root: @@ -67,17 +92,20 @@ Run the local convention checks from the repository root: ```bash ./scripts/dev/workflow-check branch ./scripts/dev/workflow-check commit 'Describe one reviewable outcome' -./scripts/dev/workflow-check commits origin/dev +./scripts/dev/workflow-check commits ./scripts/dev/workflow-check pr-title 'Describe the pull request outcome' ./scripts/dev/workflow-check pr-base dev +./scripts/dev/workflow-check pr-route group/g0-operator-surface flow ./scripts/dev/workflow-check pr-body .cache/dev/pr-body.md -./scripts/dev/workflow-check all origin/dev +./scripts/dev/workflow-check all ``` -An explicit commit base must resolve to the current `origin/dev`; callers cannot narrow the range -to hide introduced commits. `all` checks only the current branch and every introduced non-merge -commit. Run the three `pr-*` commands separately. The fast gate exercises the checker's executable -contract; it cannot inspect hosted pull-request metadata. +`commits` and `all` derive `origin/dev`, `origin/flow`, or the matching remote parent from the current +branch. If supplied, an explicit base must resolve to that same derived ref; callers cannot narrow +the range to hide introduced commits. `all` checks only the branch and every introduced non-merge +commit. Run the applicable `pr-*` commands separately; `pr-base` derives the expected base from the +current branch, while `pr-route` can check an explicit head/base pair. The fast gate exercises this +executable contract but cannot inspect hosted PR metadata or GitHub rulesets. ### Shared-checkout coordination @@ -121,9 +149,10 @@ The three local gate entry points corresponding to the required-gates workers ar ./scripts/dev/docker-gate ``` -For agent-managed single- or multi-slice delivery, use the workstream workflow in -[`docs/workstreams.md`](workstreams.md). It preserves per-slice PR and commit evidence while keeping -the final merge into `dev` human-owned. +For agent-managed single- or multi-slice work and `flow` delivery programs, use +[`docs/workstreams.md`](workstreams.md). It preserves reviewed merge ancestry and per-head evidence, +limits agents to verified intermediate integration, and keeps every final merge into `dev` +human-owned. | Gate | What it establishes | Important prerequisites | |---|---|---| diff --git a/docs/serena.md b/docs/serena.md index 820c288..5d53956 100644 --- a/docs/serena.md +++ b/docs/serena.md @@ -6,11 +6,12 @@ helper, not a service used by workloads launched through `scripts/agent`. ## The 30-second version -Remember these five facts: +Remember these facts: - the Serena project is `agent-lab-dev`; - Serena sees this repository at `/workspace`; - semantic analysis covers `.sh` and `.bash` files; +- Git and GitHub branch state come from host-side repository commands, not Serena; - build the contained toolchain once with `./scripts/dev/serena-build`; - at the start of a coding session, activate `/workspace` explicitly and prove readiness with a live symbol query. @@ -168,6 +169,18 @@ is active. Serena supplements ordinary search and the repository checks. It replaces neither. +### Branch-workflow orientation + +Serena's project prompt tells agents to establish branch and worktree state with the host-side +`./scripts/dev/brief` and `./scripts/dev/changed` commands before editing. For `flow`, group, or +slice work, [the workstream contract](workstreams.md) and `scripts/dev/workstream` remain the +integration authority. Serena cannot establish the current GitHub PR state, required-check state, +or permission to mutate a protected branch. + +Keep workflow prose, YAML, and extensionless Bash rails on the ordinary search/edit path. Serena is +still useful for supported `.sh` and `.bash` helpers reached from those rails, but its result is +semantic evidence only; the repository gates supply behavioral and security evidence. + ## What a healthy integration proves Keep these states separate: @@ -268,10 +281,10 @@ SERENA_HOME="$(mktemp -d)" serena project create . \ --ls bash ``` -The tracked `.serena/project.yml` selects Bash, UTF-8, the LSP backend, Git-ignore handling, and the -single workspace root `.`. It adds no external workspace folders and does not over-ignore source or -tests. No project memories are currently persisted; repository guidance is sufficient and remains -canonical. +The tracked `.serena/project.yml` selects Bash, UTF-8, the LSP backend, Git-ignore handling, the +preseeded Bash-language-server version, and the single workspace root `.`. It adds no external +workspace folders and does not over-ignore source or tests. No project memories are currently +persisted; repository guidance is sufficient and remains canonical. `scripts/serena-mcp` starts the one-shot `compose.serena.yaml` service. Every bind is private and non-recursive. Startup fails closed on child mounts, visible nested `.git` objects, nested diff --git a/docs/workstreams.md b/docs/workstreams.md index 1b671bd..0e18b7a 100644 --- a/docs/workstreams.md +++ b/docs/workstreams.md @@ -1,55 +1,170 @@ -# Agent-managed workstreams +# Agent-managed workstreams and programs -A workstream lets an agent integrate one or more independently tested slices without receiving -authority over `dev`. The same workflow applies to a one-slice task and a longer delivery program. +The workstream helper supports two bounded integration shapes without granting general merge +authority. Legacy workstreams collect unrelated repository slices under `work/*`. Delivery programs +collect major groups under the protected `flow` branch. `dev` remains authoritative. ```text dev -`-- work/ - |-- slice// - `-- slice// +|-- ordinary work branch ------------------------------> dev (human merge) +|-- work/ +| `-- slice// -----------> work/ --> dev (human final merge) +`-- flow [protected] + `-- group/ + `-- slice/group// -> group/ -> flow + `-> dev (human final merge) ``` -Start a workstream from a clean checkout. The command fetches `origin/dev`, creates the remote -workstream ref at that exact commit, and switches to its tracking branch: +## Exact routes + +| Head branch | Sync/rebase base | Pull-request base | Merge owner | +|---|---|---|---| +| ordinary branch | `origin/dev` | `dev` | human | +| `work/` | merge `origin/dev` via `sync` | `dev` | human | +| `slice//` | `origin/work/` | `work/` | verified helper | +| `group/` | merge `origin/flow` via `sync` | `flow` | verified helper after approval | +| `slice/group//` | merge `origin/group/` via `sync` | `group/` | verified helper | +| protected `flow` | no agent sync or write | `dev` | human | + +`` and `` are 1–48 lowercase alphanumeric/hyphen characters and start alphanumeric. +`` is also at most 48 characters and matches +`[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*`. The word `group` in the group-slice form is literal. Legacy +slices cannot target a group or `flow`, and program branches cannot target a legacy workstream. + +## R0 bootstrap + +`flow` does not exist until the R0 rail-maintenance PR has been reviewed and merged into `dev`. A +human must record that exact R0-updated `dev` commit, create `flow` at that exact SHA, and install the +hosted protections below before any group is created. A prior `dev` commit, a later moving ref, or an +agent-created substitute is not an accepted bootstrap. This creation is the sole supported +zero-predecessor `flow` push and still runs CI and CodeQL. + +## Legacy workstream + +From a clean checkout, create the remote workstream at the fetched `origin/dev` commit and switch to +its tracking branch: ```bash -./scripts/dev/workstream start +./scripts/dev/workstream start ``` -Create each slice from the current remote workstream tip: +Create a slice from the current remote workstream tip, develop and verify it, then publish its exact +route: ```bash ./scripts/dev/workstream slice -# develop, test, commit, and push slice// +# develop, test, commit, sync, and push slice// ./scripts/dev/workstream pr --title "..." --body-file /tmp/pr-body.md ``` -Read the PR, checks, Actions logs, and review state. After every check has completed successfully, -integrate the slice with: +After approval and current-head checks, switch to the matching `work/` branch and integrate the +slice: ```bash ./scripts/dev/workstream merge ``` -The merge command rereads immutable PR metadata immediately before the operation. It requires an -open, non-draft, cleanly mergeable `slice//` PR into the matching `work/`, rejects -requested changes, requires a successful `Required gates` check, requires every reported check to -be completed and successful, and pins the merge to the observed head commit. It always preserves -slice commits with a merge commit. Direct PR merge commands remain blocked. +When the workstream is complete, sync it, replay final gates, push it, and open the human-owned draft +PR: + +```bash +./scripts/dev/workstream final --title "..." --body-file /tmp/pr-body.md +``` -When all planned slices are integrated, switch to the workstream branch, inspect the complete range, -run final gates, and open the human-owned integration PR: +That final PR always targets `dev`; an agent cannot merge it. + +## Program group + +After the human R0 bootstrap, create a group at the fetched `origin/flow` commit: + +```bash +./scripts/dev/workstream group g0-operator-surface +``` + +A group may be developed directly or split into reviewable slices. From the group branch: + +```bash +./scripts/dev/workstream slice cli +# develop, test, commit, sync, and push slice/group/g0-operator-surface/cli +./scripts/dev/workstream pr --title "..." --body-file /tmp/pr-body.md +``` + +Each group slice must be approved, current, and green before the helper merges it from the matching +group checkout. When the group is complete, sync it with `flow`, replay the complete evidence, push +it, and open its fixed draft PR: + +```bash +./scripts/dev/workstream group-pr --title "..." --body-file /tmp/pr-body.md +``` + +The group PR always targets `flow` and starts as a draft. A human makes it ready and supplies the +required approval. Once its current head contains the observed current `flow` base and every required +check succeeds, an agent may check out read-only `flow` and run: + +```bash +./scripts/dev/workstream merge +``` + +The resulting `flow` head must complete CI and CodeQL before another group integrates. After all +groups are integrated, a human or an agent may open the final draft while checked out on `flow`: ```bash ./scripts/dev/workstream final --title "..." --body-file /tmp/pr-body.md ``` -The final PR always targets `dev` and is always created as a draft. Agents cannot merge it. +Only a human merges that final non-squash `flow` → `dev` PR. + +## Synchronization and verified merge + +Run `./scripts/dev/workstream sync` only from a clean `work/*`, `group/*`, or matching slice branch. +For `work/*` and `group/*`, it fetches both the branch's own remote integration ref and its derived +`dev`/`flow` parent, then fast-forwards the local branch to its remote ref. It refuses local/remote +divergence instead of rewriting accepted history. Slice creation performs the same refresh before it +branches. Reusable `work/*`, program `group/*`, and `slice/group/*/*` always merge their moving +parent; none may be rebased or force-updated after publication. A legacy slice may rebase only on +its matching workstream. Replay invalidated evidence after any sync. + +`workstream merge` rereads hosted PR state immediately before acting. It accepts only a same-repository +PR whose base is the current checkout and whose head is the exact matching slice or group route. The +PR must be open, non-draft, cleanly mergeable, approved, contain its observed current base, report +exactly one successful `Required gates` and `CodeQL` check, and have every reported check completed +successfully. Group integration also requires those checks green on the observed `flow` base. +Every GitHub read and write is pinned to `github.com/uscient/agent-lab`; ambient repository or host +environment variables cannot redirect the helper. +The command requests a merge commit pinned to the observed head SHA and confirms GitHub reports the +PR as merged. It never squashes, rebases, force-updates, or deletes a branch. Direct `gh pr merge` +remains blocked. If GitHub queues the PR, an open PR is not reported as completed; treat it as pending +until the queue records `MERGED` on the same checked head. + +## Human GitHub configuration + +Repository files test the client-side route, but humans must install and audit the hosted rules: + +- Protect `dev`, `flow`, `master`, `main`, `work/**`, `group/**`, and `slice/group/**`; prohibit unauthorized + direct pushes, every program-branch force update, and deletion. +- Require pull requests, `CI / Required gates`, and `CodeQL` on every protected branch that receives + changes. Require the PR head to contain the current base or use the merge queue; the helper pins + the head, but only this hosted rule closes a base movement between validation and merge. +- Require approval, dismiss stale approval after new commits, and require approval of the latest + reviewable push. Rules for intermediate bases must preserve the helper's same guarantees. +- Permit merge commits and disable squash/rebase merging. Disable automatic head-branch deletion; + retain program branches and PR records through final `flow` review. +- Require trusted human ownership for every rail in `policy/protected.paths`, including workflows, + gate manifests, reducers, the workflow checker/helper, guards, and their contract tests. The owner + must be a real maintainer team with repository write authority. + +Required checks must bind to the current head. A base change, dependency merge, workflow or manifest +change, rebase, or merge invalidates older green evidence and requires replay. Skipped, cancelled, +missing, stale, duplicate, or infrastructure-uncertain results are not green. -## Project guides +## Project guides and evidence -Files under ignored `proj/` may define slice order, contracts, dependencies, RED/GREEN/mutation -evidence, and stop conditions. A guide coordinates work but grants no authority and cannot weaken -`AGENTS.md`, the workstream command, required checks, or containment. Record the workstream slug, -base commit, ordered slice names, per-slice branch/PR, evidence ledger, and remaining uncertainty. +Files under ignored `proj/` may define group order, slice contracts, dependencies, behavior +scenarios, RED/GREEN/mutation evidence, and stop conditions. A guide coordinates work but grants no +authority and cannot weaken `AGENTS.md`, hosted rules, the helper, required checks, or containment. +The cadence is Behavior-Driven, Test-Driven, and Security-Driven: start from behavior scenarios, make +their behavior and security assertions RED, implement to GREEN, then run product and test/CI +sensitivity mutations before final gates. +Record exact base and head commits, the PR route, commands and results, approvals, mutations, +artifacts, cleanup, superseding runs, and remaining uncertainty. Append new evidence; do not erase +the record a later run supersedes. diff --git a/policy/deny.patterns b/policy/deny.patterns index 74b1376..d696360 100644 --- a/policy/deny.patterns +++ b/policy/deny.patterns @@ -1,7 +1,7 @@ # policy/deny.patterns — unconditional control-plane denials (extended regex; one per line). # Read by tools/pretooluse-guard.sh; tools/codex-permission-request.sh delegates to that guard. -# AGENTS.md is the operating-policy source. Scoped workflow decisions (origin/dev rebase, -# same-branch push, and PR creation to dev) are implemented by the guard and argv shims. +# AGENTS.md is the operating-policy source. Branch-derived rebase and PR routes, same-branch push, +# and protected integration decisions are implemented by the guard, argv shims, and workstream helper. # # The git<->subcommand separator is [^[:alnum:]_]+ (not just whitespace) so the common quoted/ # bracketed argv forms are caught too (e.g. subprocess(["git","push"])). The leading anchor diff --git a/policy/protected.paths b/policy/protected.paths index 3c93d6a..024a9eb 100644 --- a/policy/protected.paths +++ b/policy/protected.paths @@ -24,5 +24,31 @@ scripts/dev/serena-smoke scripts/lib/serena.sh scripts/serena-mcp tools/serena-entrypoint.sh +.github/workflows/ +scripts/dev/workstream +scripts/dev/workflow-check +scripts/dev/ci-fast +scripts/dev/required-gates +scripts/dev/security-gate +scripts/dev/security-gate.py +scripts/dev/check +scripts/dev/test +scripts/dev/docker-gate +scripts/dev/lint-scripts +scripts/dev/cue-tool +scripts/dev/cedar-tool +tools/validate.sh +tools/containment-lint.sh +tests/security/ +tests/dev/workstream-cases.sh +tests/dev/workflow-check-cases.sh +tests/dev/ci-workflow-cases.sh +tests/dev/required-gates-cases.sh +tests/dev/security-gate-cases.sh +tests/dev/guard-diff-cases.sh +tests/guard/ +tests/agent/policy-verify.sh +tests/agent/render-adapters-idempotence.sh +tests/serena/ .cursor/ .devguard/ diff --git a/scripts/dev/required-gates b/scripts/dev/required-gates index fbf3450..063c81a 100755 --- a/scripts/dev/required-gates +++ b/scripts/dev/required-gates @@ -213,6 +213,13 @@ fi if [ -z "${CI_NEEDS_JSON+x}" ]; then finish_infra "\`CI_NEEDS_JSON\` is unset." fi +if [ -z "${CI_EXPECTED_HEAD+x}" ]; then + finish_infra "\`CI_EXPECTED_HEAD\` is unset." +fi +if [[ ! "$CI_EXPECTED_HEAD" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || + [[ "$CI_EXPECTED_HEAD" =~ ^0+$ ]]; then + finish_infra "\`CI_EXPECTED_HEAD\` is not a valid immutable commit identity." +fi if ! printf '%s' "$CI_NEEDS_JSON" | jq -e 'type == "object"' >/dev/null 2>&1; then finish_infra "\`CI_NEEDS_JSON\` is not a valid JSON object." fi @@ -227,9 +234,22 @@ if [ "$actual_keys" != "$expected_keys" ]; then finish_infra "\`CI_NEEDS_JSON\` must contain exactly the fast, static, and docker jobs." fi +for gate_id in "${gate_ids[@]}"; do + gate_head="$( + printf '%s' "$CI_NEEDS_JSON" | + jq -er --arg gate "$gate_id" \ + '.[$gate].outputs["tested-head"] | select(type == "string")' 2>/dev/null + )" || gate_head="" + if [ "$gate_head" != "$CI_EXPECTED_HEAD" ]; then + finish_infra "job \`$gate_id\` did not bind evidence to the expected tested head." + fi +done + invalid_result="" blocked=0 blocked_gates=() +infra_blocked=0 +infra_gates=() fast_result="" for gate_id in "${gate_ids[@]}"; do if ! printf '%s' "$CI_NEEDS_JSON" | @@ -248,14 +268,40 @@ for gate_id in "${gate_ids[@]}"; do break } gate_results+=("$result") + classification="$( + printf '%s' "$CI_NEEDS_JSON" | + jq -er --arg gate "$gate_id" \ + '.[$gate].outputs.classification | select(type == "string")' 2>/dev/null + )" || classification="" if [ "$gate_id" = "fast" ]; then fast_result="$result" fi case "$result" in - success) ;; - failure|cancelled|skipped) - blocked=1 - blocked_gates+=("\`$gate_id\` (\`$result\`)") + success) + if [ "$classification" != success ]; then + infra_blocked=1 + infra_gates+=("\`$gate_id\` (success with \`${classification:-missing}\` classification)") + fi + ;; + failure) + case "$classification" in + assertion-failure) + blocked=1 + blocked_gates+=("\`$gate_id\` (\`failure\`)") + ;; + infrastructure | unexpected) + infra_blocked=1 + infra_gates+=("\`$gate_id\` (\`$classification\`)") + ;; + *) + infra_blocked=1 + infra_gates+=("\`$gate_id\` (failure with \`${classification:-missing}\` classification)") + ;; + esac + ;; + cancelled | skipped) + infra_blocked=1 + infra_gates+=("\`$gate_id\` (\`$result\`)") ;; *) invalid_result="$gate_id" @@ -280,6 +326,17 @@ if [[ ! "$fast_diff_base" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]] || fi fi +if [ "$infra_blocked" -ne 0 ]; then + infra_list="" + for infra_gate in "${infra_gates[@]}"; do + if [ -n "$infra_list" ]; then + infra_list+=", " + fi + infra_list+="$infra_gate" + done + finish_infra "required job evidence is incomplete or uncertain: $infra_list." +fi + if [ "$blocked" -ne 0 ]; then blocked_list="" for blocked_gate in "${blocked_gates[@]}"; do diff --git a/scripts/dev/workflow-check b/scripts/dev/workflow-check index f92cf96..5568250 100755 --- a/scripts/dev/workflow-check +++ b/scripts/dev/workflow-check @@ -12,6 +12,7 @@ usage: scripts/dev/workflow-check branch [name] scripts/dev/workflow-check pr-title scripts/dev/workflow-check pr-body <file> scripts/dev/workflow-check pr-base <base> + scripts/dev/workflow-check pr-route <head> <base> scripts/dev/workflow-check all [base] EOF return 2 @@ -91,11 +92,34 @@ check_branch() { return fi case "$branch_name" in - dev | master | main) + dev | flow | master | main) fail "branch is protected: $branch_name" return ;; esac + case "$branch_name" in + work/*) + [[ "$branch_name" =~ ^work/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || { fail "invalid reserved workstream branch: $branch_name"; return; } + ;; + group/*) + valid_group "${branch_name#group/}" \ + || { fail "invalid reserved group branch: $branch_name"; return; } + ;; + slice/group/*/*) + [[ "$branch_name" =~ ^slice/group/([gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*)/[a-z0-9][a-z0-9-]{0,47}$ ]] && + valid_group "${BASH_REMATCH[1]}" \ + || { fail "invalid reserved group slice branch: $branch_name"; return; } + ;; + slice/*/*) + [[ "$branch_name" =~ ^slice/[a-z0-9][a-z0-9-]{0,47}/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || { fail "invalid reserved workstream slice branch: $branch_name"; return; } + ;; + slice/*) + fail "invalid reserved branch: $branch_name" + return + ;; + esac lower="${branch_name,,}" case "$lower" in agent | changes | codex | claude | grok | now | work) @@ -123,6 +147,10 @@ check_branch() { printf 'PASS workflow branch=%s\n' "$branch_name" } +valid_group() { + [ "${#1}" -le 48 ] && [[ "$1" =~ ^[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*$ ]] +} + check_current_or_named_branch() { local branch_name @@ -175,14 +203,86 @@ check_pr_title() { printf 'PASS workflow PR title\n' } +expected_pr_base() { + local head="$1" group="" + case "$head" in + flow) printf 'dev\n' ;; + work/*) + [[ "$head" =~ ^work/[a-z0-9][a-z0-9-]{0,47}$ ]] || return 1 + printf 'dev\n' + ;; + group/*) + valid_group "${head#group/}" || return 1 + printf 'flow\n' + ;; + slice/group/*/*) + [[ "$head" =~ ^slice/group/([gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*)/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + group="${BASH_REMATCH[1]}" + valid_group "$group" || return 1 + printf 'group/%s\n' "$group" + ;; + slice/*/*) + [[ "$head" =~ ^slice/([a-z0-9][a-z0-9-]{0,47})/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + printf 'work/%s\n' "${BASH_REMATCH[1]}" + ;; + slice/* | dev | master | main) return 1 ;; + *) printf 'dev\n' ;; + esac +} + +expected_commit_base() { + local head="$1" group="" + case "$head" in + flow) printf 'origin/dev\n' ;; + work/*) + [[ "$head" =~ ^work/[a-z0-9][a-z0-9-]{0,47}$ ]] || return 1 + printf 'origin/dev\n' + ;; + group/*) + valid_group "${head#group/}" || return 1 + printf 'origin/flow\n' + ;; + slice/group/*/*) + [[ "$head" =~ ^slice/group/([gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*)/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + group="${BASH_REMATCH[1]}" + valid_group "$group" || return 1 + printf 'origin/group/%s\n' "$group" + ;; + slice/*/*) + [[ "$head" =~ ^slice/([a-z0-9][a-z0-9-]{0,47})/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + printf 'origin/work/%s\n' "${BASH_REMATCH[1]}" + ;; + slice/* | dev | master | main) return 1 ;; + *) printf 'origin/dev\n' ;; + esac +} + +check_pr_route() { + local head="$1" base="$2" expected="" + + expected="$(expected_pr_base "$head")" || { + fail "PR head is not an authorized workflow branch: $head" + return + } + if [[ "$base" != "$expected" ]]; then + fail "PR base for $head must be $expected" + return + fi + printf 'PASS workflow PR route=%s->%s\n' "$head" "$base" +} + check_pr_base() { - local base="$1" + local base="$1" head="" - if [[ "$base" != dev ]]; then - fail "PR base must be dev" + if ! head="$(git branch --show-current 2>/dev/null)" || [[ -z "$head" ]]; then + infra "cannot inspect the head branch for the PR base" return fi - printf 'PASS workflow PR base=dev\n' + check_pr_route "$head" "$base" } strip_nonprose_markdown() { @@ -354,23 +454,34 @@ check_pr_body() { } check_commits() { - local base="$1" base_oid integration_oid revisions hash author_name author_email + local base="$1" branch_name expected_base base_oid integration_oid revisions hash author_name author_email local subject message trailers ancestry_rc=0 count=0 if ! command -v git >/dev/null 2>&1; then infra "cannot inspect commits without git" return fi + if ! branch_name="$(git branch --show-current 2>/dev/null)" || [[ -z "$branch_name" ]]; then + infra "cannot inspect current branch" + return + fi + expected_base="$(expected_commit_base "$branch_name")" || { + fail "commit range is not authorized from branch: $branch_name" + return + } + if [[ -z "$base" ]]; then + base="$expected_base" + fi if ! base_oid="$(GIT_NO_REPLACE_OBJECTS=1 git rev-parse --verify "${base}^{commit}" 2>/dev/null)"; then infra "invalid base: $base" return fi - if ! integration_oid="$(GIT_NO_REPLACE_OBJECTS=1 git rev-parse --verify 'origin/dev^{commit}' 2>/dev/null)"; then - infra "cannot resolve origin/dev" + if ! integration_oid="$(GIT_NO_REPLACE_OBJECTS=1 git rev-parse --verify "${expected_base}^{commit}" 2>/dev/null)"; then + infra "cannot resolve $expected_base" return fi if [[ "$base_oid" != "$integration_oid" ]]; then - fail "commit base must resolve to origin/dev: $base" + fail "commit base must resolve to $expected_base: $base" return fi if ! GIT_NO_REPLACE_OBJECTS=1 git rev-parse --verify 'HEAD^{commit}' >/dev/null 2>&1; then @@ -458,7 +569,7 @@ main() { ;; commits) (($# <= 1)) || { usage; return; } - check_commits "${1:-origin/dev}" + check_commits "${1:-}" ;; pr-title) (($# == 1)) || { usage; return; } @@ -472,9 +583,13 @@ main() { (($# == 1)) || { usage; return; } check_pr_base "$1" ;; + pr-route) + (($# == 2)) || { usage; return; } + check_pr_route "$1" "$2" + ;; all) (($# <= 1)) || { usage; return; } - check_all "${1:-origin/dev}" + check_all "${1:-}" ;; *) usage diff --git a/scripts/dev/workstream b/scripts/dev/workstream index 2c194fa..20b416d 100755 --- a/scripts/dev/workstream +++ b/scripts/dev/workstream @@ -2,14 +2,18 @@ set -euo pipefail repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." >/dev/null 2>&1 && pwd)" +cd "$repo_root" usage() { cat >&2 <<'EOF' Usage: scripts/dev/workstream start SLUG + scripts/dev/workstream group NAME scripts/dev/workstream slice NAME + scripts/dev/workstream sync scripts/dev/workstream pr [gh-pr-create options] scripts/dev/workstream merge PR_NUMBER + scripts/dev/workstream group-pr [gh-pr-create options] scripts/dev/workstream final [gh-pr-create options] EOF exit 2 @@ -22,22 +26,38 @@ valid_component() { [[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,47}$ ]] } +valid_group() { + valid_component "$1" && [[ "$1" =~ ^[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*$ ]] +} + +real_git="" real_gh="" -shim="$(readlink -f "$repo_root/tools/bin/gh" 2>/dev/null || true)" -while IFS= read -r candidate; do - resolved="$(readlink -f "$candidate" 2>/dev/null || printf '%s' "$candidate")" - [ "$resolved" = "$shim" ] && continue - real_gh="$candidate" - break -done < <(type -aP gh 2>/dev/null) -[ -n "$real_gh" ] || die "gh is unavailable" -command -v jq >/dev/null 2>&1 || die "jq is unavailable" +real_jq="" +for candidate in /usr/bin/git /usr/local/bin/git /bin/git; do + [ -x "$candidate" ] && { real_git="$candidate"; break; } +done +for candidate in /usr/bin/gh /usr/local/bin/gh; do + [ -x "$candidate" ] && { real_gh="$candidate"; break; } +done +for candidate in /usr/bin/jq /usr/local/bin/jq /bin/jq; do + [ -x "$candidate" ] && { real_jq="$candidate"; break; } +done +[ -n "$real_git" ] || die "git is unavailable from a trusted system path" +[ -n "$real_gh" ] || die "gh is unavailable from a trusted system path" +[ -n "$real_jq" ] || die "jq is unavailable from a trusted system path" +repository="uscient/agent-lab" +repository_target="github.com/$repository" current_branch() { - git -C "$repo_root" symbolic-ref --short -q HEAD 2>/dev/null \ + "$real_git" -C "$repo_root" symbolic-ref --short -q HEAD 2>/dev/null \ || die "the checkout is detached" } +require_clean() { + [ -z "$("$real_git" -C "$repo_root" status --porcelain)" ] \ + || refuse "the tracked checkout is not clean" +} + reject_routing_options() { local argument for argument in "$@"; do @@ -59,18 +79,29 @@ case "$command_name" in [ "$#" -eq 1 ] || usage slug="$1" valid_component "$slug" || refuse "invalid workstream slug: $slug" - [ -z "$(git -C "$repo_root" status --porcelain)" ] || refuse "the tracked checkout is not clean" - git -C "$repo_root" fetch origin dev - dev_sha="$(git -C "$repo_root" rev-parse --verify origin/dev)" \ + require_clean + "$real_git" -C "$repo_root" fetch origin dev + dev_sha="$("$real_git" -C "$repo_root" rev-parse --verify origin/dev)" \ || die "origin/dev is unavailable" - repository="$($real_gh repo view --json nameWithOwner --jq .nameWithOwner)" \ - || die "cannot resolve the current GitHub repository" - [ -n "$repository" ] || die "GitHub returned an empty repository identity" - "$real_gh" api --method POST "repos/$repository/git/refs" \ + "$real_gh" api "repos/$repository/git/refs" --hostname github.com --method POST \ -f "ref=refs/heads/work/$slug" -f "sha=$dev_sha" >/dev/null \ || die "cannot create work/$slug at origin/dev" - git -C "$repo_root" fetch origin "work/$slug" - git -C "$repo_root" switch -c "work/$slug" --track "origin/work/$slug" + "$real_git" -C "$repo_root" fetch origin "work/$slug" + "$real_git" -C "$repo_root" switch -c "work/$slug" --track "origin/work/$slug" + ;; + group) + [ "$#" -eq 1 ] || usage + group="$1" + valid_group "$group" || refuse "invalid group name: $group" + require_clean + "$real_git" -C "$repo_root" fetch origin flow + flow_sha="$("$real_git" -C "$repo_root" rev-parse --verify origin/flow)" \ + || die "origin/flow is unavailable" + "$real_gh" api "repos/$repository/git/refs" --hostname github.com --method POST \ + -f "ref=refs/heads/group/$group" -f "sha=$flow_sha" >/dev/null \ + || die "cannot create group/$group at origin/flow" + "$real_git" -C "$repo_root" fetch origin "group/$group" + "$real_git" -C "$repo_root" switch -c "group/$group" --track "origin/group/$group" ;; slice) [ "$#" -eq 1 ] || usage @@ -78,65 +109,209 @@ case "$command_name" in valid_component "$name" || refuse "invalid slice name: $name" branch="$(current_branch)" case "$branch" in - work/*) slug="${branch#work/}" ;; - *) refuse "slice creation requires the matching work/<slug> branch" ;; + work/*) + slug="${branch#work/}" + valid_component "$slug" || refuse "invalid current workstream branch" + base="work/$slug" + slice_branch="slice/$slug/$name" + ;; + group/*) + group="${branch#group/}" + valid_group "$group" || refuse "invalid current group branch" + base="group/$group" + slice_branch="slice/group/$group/$name" + ;; + *) refuse "slice creation requires a work/<slug> or group/<id>-<slug> branch" ;; + esac + require_clean + "$real_git" -C "$repo_root" fetch origin "$base" + "$real_git" -C "$repo_root" merge --ff-only "origin/$base" \ + || refuse "$branch diverged from its integration ref; refusing to rewrite accepted history" + "$real_git" -C "$repo_root" switch -c "$slice_branch" "origin/$base" + ;; + sync) + [ "$#" -eq 0 ] || usage + require_clean + branch="$(current_branch)" + case "$branch" in + work/*) + slug="${branch#work/}" + valid_component "$slug" || refuse "invalid current workstream branch" + base="dev" + sync_mode="merge" + ;; + group/*) + group="${branch#group/}" + valid_group "$group" || refuse "invalid current group branch" + base="flow" + sync_mode="merge" + ;; + slice/group/*/*) + remainder="${branch#slice/group/}" + group="${remainder%%/*}" + name="${remainder#*/}" + valid_group "$group" && valid_component "$name" \ + || refuse "invalid group slice branch" + base="group/$group" + sync_mode="merge" + ;; + slice/*/*) + remainder="${branch#slice/}" + slug="${remainder%%/*}" + name="${remainder#*/}" + valid_component "$slug" && valid_component "$name" \ + || refuse "invalid workstream slice branch" + base="work/$slug" + sync_mode="rebase" + ;; + *) refuse "sync requires a workstream, group, or matching slice branch" ;; + esac + if [[ "$branch" == group/* || "$branch" == work/* ]]; then + "$real_git" -C "$repo_root" fetch origin "$branch" "$base" + "$real_git" -C "$repo_root" merge --ff-only "origin/$branch" \ + || refuse "$branch diverged from its integration ref; refusing to rewrite accepted history" + else + "$real_git" -C "$repo_root" fetch origin "$base" + fi + case "$sync_mode" in + merge) + "$real_git" -C "$repo_root" merge --no-ff --no-edit "origin/$base" + ;; + rebase) "$real_git" -C "$repo_root" rebase "origin/$base" ;; esac - valid_component "$slug" || refuse "invalid current workstream branch" - [ -z "$(git -C "$repo_root" status --porcelain)" ] || refuse "the tracked checkout is not clean" - git -C "$repo_root" fetch origin "work/$slug" - git -C "$repo_root" switch -c "slice/$slug/$name" "origin/work/$slug" ;; pr) reject_routing_options "$@" branch="$(current_branch)" case "$branch" in + slice/group/*/*) + remainder="${branch#slice/group/}" + group="${remainder%%/*}" + name="${remainder#*/}" + valid_group "$group" && valid_component "$name" \ + || refuse "invalid group slice branch" + base="group/$group" + ;; slice/*/*) remainder="${branch#slice/}" slug="${remainder%%/*}" name="${remainder#*/}" + valid_component "$slug" && valid_component "$name" \ + || refuse "invalid workstream slice branch" + base="work/$slug" ;; - *) refuse "slice PR creation requires slice/<slug>/<name>" ;; + *) refuse "slice PR creation requires a matching slice branch" ;; esac - valid_component "$slug" && valid_component "$name" \ - || refuse "invalid slice branch" - exec "$real_gh" pr create --base "work/$slug" --head "$branch" "$@" + exec "$real_gh" pr create --repo "$repository_target" --base "$base" --head "$branch" "$@" ;; merge) [ "$#" -eq 1 ] || usage pr_number="$1" [[ "$pr_number" =~ ^[1-9][0-9]*$ ]] || refuse "PR number must be a positive integer" + branch="$(current_branch)" + case "$branch" in + work/*) valid_component "${branch#work/}" || refuse "invalid current workstream branch" ;; + group/*) valid_group "${branch#group/}" || refuse "invalid current group branch" ;; + flow) ;; + *) refuse "merge requires a work/<slug>, group/<id>-<slug>, or flow integration branch" ;; + esac + require_clean metadata="$($real_gh pr view "$pr_number" \ - --json number,state,isDraft,baseRefName,headRefName,headRefOid,mergeStateStatus,reviewDecision,statusCheckRollup)" \ + --repo "$repository_target" \ + --json number,state,isDraft,baseRefName,baseRefOid,headRefName,headRefOid,isCrossRepository,headRepository,mergeStateStatus,reviewDecision,statusCheckRollup)" \ || die "cannot read PR $pr_number" - if ! printf '%s' "$metadata" | jq -e ' - .state == "OPEN" and + if ! printf '%s' "$metadata" | "$real_jq" -e \ + --argjson number "$pr_number" --arg branch "$branch" --arg repository "$repository" ' + (.number == $number) and + (.state == "OPEN") and (.isDraft == false) and - (.baseRefName | test("^work/[a-z0-9][a-z0-9-]{0,47}$")) and - (.headRefName | test("^slice/[a-z0-9][a-z0-9-]{0,47}/[a-z0-9][a-z0-9-]{0,47}$")) and - ((.baseRefName | sub("^work/"; "")) == - (.headRefName | capture("^slice/(?<slug>[^/]+)/").slug)) and + (.isCrossRepository == false) and + (.headRepository.nameWithOwner == $repository) and + (.baseRefName == $branch) and + (((.baseRefName | test("^work/[a-z0-9][a-z0-9-]{0,47}$")) and + (.headRefName | test("^slice/[a-z0-9][a-z0-9-]{0,47}/[a-z0-9][a-z0-9-]{0,47}$")) and + ((.baseRefName | sub("^work/"; "")) == + (.headRefName | capture("^slice/(?<slug>[^/]+)/").slug))) or + ((.baseRefName | test("^group/[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*$")) and + (.headRefName | test("^slice/group/[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]{0,47}$")) and + ((.baseRefName | sub("^group/"; "")) == + (.headRefName | capture("^slice/group/(?<group>[^/]+)/").group))) or + ((.baseRefName == "flow") and + (.headRefName | test("^group/[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*$")) and + ((.headRefName | sub("^group/"; "") | length) <= 48))) and + (.baseRefOid | test("^[0-9a-f]{40}$")) and (.headRefOid | test("^[0-9a-f]{40}$")) and (.mergeStateStatus == "CLEAN") and - (.reviewDecision != "CHANGES_REQUESTED") and + (.reviewDecision == "APPROVED") and ((.statusCheckRollup | length) > 0) and - (any(.statusCheckRollup[]; .name == "Required gates" and - .status == "COMPLETED" and .conclusion == "SUCCESS")) and + ((.statusCheckRollup | map(.name) | unique | length) == + (.statusCheckRollup | length)) and + (([.statusCheckRollup[] | select(.name == "Required gates" and + .status == "COMPLETED" and .conclusion == "SUCCESS")] | length) == 1) and + (([.statusCheckRollup[] | select(.name == "CodeQL" and + .status == "COMPLETED" and .conclusion == "SUCCESS")] | length) == 1) and (all(.statusCheckRollup[]; .status == "COMPLETED" and .conclusion == "SUCCESS")) ' >/dev/null; then - refuse "PR $pr_number is not a clean matching slice with every check successful" + refuse "PR $pr_number is not an approved current-base intermediate PR with every check successful" fi - head_sha="$(printf '%s' "$metadata" | jq -r .headRefOid)" - exec "$real_gh" pr merge "$pr_number" --merge --match-head-commit "$head_sha" + base_sha="$(printf '%s' "$metadata" | "$real_jq" -r .baseRefOid)" + head_sha="$(printf '%s' "$metadata" | "$real_jq" -r .headRefOid)" + comparison="$($real_gh api "repos/$repository/compare/$base_sha...$head_sha" \ + --hostname github.com --jq .status)" \ + || die "cannot prove PR $pr_number contains its observed base" + case "$comparison" in + ahead | identical) ;; + *) refuse "PR $pr_number head does not contain its observed base" ;; + esac + if [ "$branch" = flow ]; then + base_checks="$($real_gh api "repos/$repository/commits/$base_sha/check-runs?per_page=100&filter=latest" \ + --hostname github.com)" || die "cannot read checks for observed flow base $base_sha" + if ! printf '%s' "$base_checks" | "$real_jq" -e ' + (.check_runs | type == "array") and + (([.check_runs[] | select(.name == "Required gates" and + .status == "completed" and .conclusion == "success")] | length) == 1) and + (([.check_runs[] | select(.name == "CodeQL" and + .status == "completed" and .conclusion == "success")] | length) == 1) + ' >/dev/null; then + refuse "observed flow base $base_sha is not green for Required gates and CodeQL" + fi + fi + "$real_gh" pr merge "$pr_number" --repo "$repository_target" \ + --merge --match-head-commit "$head_sha" \ + || die "merge request failed for PR $pr_number" + post_state="$($real_gh pr view "$pr_number" --repo "$repository_target" \ + --json state --jq .state)" \ + || die "cannot confirm PR $pr_number after merge request" + case "$post_state" in + MERGED) printf 'PASS workstream merged PR %s at head %s\n' "$pr_number" "$head_sha" ;; + OPEN) refuse "PR $pr_number is queued or still open; integration is not yet complete" ;; + *) die "PR $pr_number ended in unexpected state: ${post_state:-<empty>}" ;; + esac + ;; + group-pr) + reject_routing_options "$@" + branch="$(current_branch)" + case "$branch" in + group/*) group="${branch#group/}" ;; + *) refuse "group PR creation requires group/<id>-<slug>" ;; + esac + valid_group "$group" || refuse "invalid current group branch" + exec "$real_gh" pr create --repo "$repository_target" \ + --base flow --head "$branch" --draft "$@" ;; final) reject_routing_options "$@" branch="$(current_branch)" case "$branch" in - work/*) slug="${branch#work/}" ;; - *) refuse "final PR creation requires work/<slug>" ;; + work/*) + slug="${branch#work/}" + valid_component "$slug" || refuse "invalid current workstream branch" + ;; + flow) ;; + *) refuse "final PR creation requires work/<slug> or protected flow" ;; esac - valid_component "$slug" || refuse "invalid current workstream branch" - exec "$real_gh" pr create --base dev --head "$branch" --draft "$@" + exec "$real_gh" pr create --repo "$repository_target" \ + --base dev --head "$branch" --draft "$@" ;; *) usage ;; esac diff --git a/tests/agent/policy-verify.sh b/tests/agent/policy-verify.sh index 30619ba..8a0ca90 100755 --- a/tests/agent/policy-verify.sh +++ b/tests/agent/policy-verify.sh @@ -23,6 +23,9 @@ if ! mkdir -p "$policy_git_repo" || exit 125 fi policy_git_dir="$policy_git_repo/.git" +set_policy_branch() { + git --git-dir="$policy_git_dir" symbolic-ref HEAD "refs/heads/$1" +} P=0 F=0 S=0 pass() { printf 'PASS %s\n' "$1"; P=$((P + 1)); } @@ -51,6 +54,8 @@ for c in 'git push' 'git push --force origin HEAD' 'git push origin dev' 'git -C 'git clean -fdx' 'rm -rf build'; do probe_cmd block "blocked: $c" "$c" done +probe_cmd block "blocked: alternate-worktree commit" 'git -C /tmp/linked-dev commit -m bad' +probe_cmd block "blocked: alternate-git-dir merge" 'git --git-dir=/tmp/linked-flow/.git merge feature' probe_cmd allow "control: local merge feature-x" 'git merge feature-x' probe_cmd allow "control: local rebase main" 'git rebase main' probe_cmd allow "control: rebase origin/dev" 'git rebase origin/dev' @@ -58,43 +63,92 @@ probe_cmd allow "control: current-branch push" 'git push -u origin HEAD' probe_cmd allow "control: lease push" 'git push --force-with-lease origin HEAD' probe_cmd allow "control: PR read" 'gh pr view 1' probe_cmd allow "control: PR create to dev" 'gh pr create --base dev --title x --body-file /tmp/body' +set_policy_branch group/g0-operator-surface +probe_cmd block "blocked: group history rewrite" 'git rebase origin/flow' +probe_cmd block "blocked: group local rewrite" 'git rebase flow' +probe_cmd block "blocked: group lease rewrite" 'git push --force-with-lease origin HEAD' +probe_cmd allow "control: group PR to flow" 'gh pr create --base flow --head group/g0-operator-surface --title x --body y' +probe_cmd block "blocked: group PR skips flow" 'gh pr create --base dev --head group/g0-operator-surface --title x --body y' +set_policy_branch slice/group/g0-operator-surface/cli +probe_cmd block "blocked: group slice history rewrite" 'git rebase origin/group/g0-operator-surface' +probe_cmd block "blocked: group slice sibling base" 'git rebase origin/group/g1-contract-growth' +set_policy_branch flow +probe_cmd allow "control: flow final PR" 'gh pr create --base dev --head flow --title x --body y' +probe_cmd block "blocked: flow push" 'git push origin HEAD' +set_policy_branch work/demo +probe_cmd block "blocked: workstream history rewrite" 'git rebase origin/dev' +probe_cmd block "blocked: workstream helper bypass" 'git merge refs/heads/slice/demo/one' +probe_cmd block "blocked: workstream lease rewrite" 'git push --force-with-lease origin HEAD' +set_policy_branch agent/test/policy echo "== shim adversarial (variable indirection — argv level) ==" if [ -x tools/bin/git ]; then shim_work="$policy_git_work/shim" shim_bin="$shim_work/bin" - if ! mkdir -p "$shim_bin"; then + shim_tools="$shim_work/tools" + if ! mkdir -p "$shim_bin" "$shim_tools"; then printf 'INFRA policy verification cannot create isolated shim state\n' >&2 exit 125 fi printf '%s\n' \ '#!/usr/bin/env bash' \ - 'if [ "${1:-}" = symbolic-ref ]; then echo agent/test/guard; exit 0; fi' \ + 'if [ "${1:-}" = symbolic-ref ]; then echo "${AGENT_LAB_SHIM_BRANCH:-agent/test/guard}"; exit 0; fi' \ 'printf "REAL-GIT %s\n" "$*"' > "$shim_bin/git" printf '%s\n' \ '#!/usr/bin/env bash' \ 'printf "REAL-GH %s\n" "$*"' > "$shim_bin/gh" chmod +x "$shim_bin/git" "$shim_bin/gh" + sed "s#/usr/bin/git#$shim_bin/git#" tools/bin/git > "$shim_tools/git" + sed -e "s#/usr/bin/gh#$shim_bin/gh#" -e "s#/usr/bin/git#$shim_bin/git#" \ + tools/bin/gh > "$shim_tools/gh" + chmod +x "$shim_tools/git" "$shim_tools/gh" for c in 'g=push; git $g' 'm=merge; git $m origin/main'; do - rc=0; out="$(PATH="$PWD/tools/bin:$shim_bin:/usr/bin:/bin" bash -c "$c" 2>&1)" || rc=$? + rc=0; out="$(PATH="$shim_tools:$shim_bin:/usr/bin:/bin" bash -c "$c" 2>&1)" || rc=$? { [ "$rc" -ne 0 ] && printf '%s' "$out" | grep -q 'BLOCKED by agent-lab policy'; } \ && pass "shim blocks: $c" || fail "shim should block: $c (rc=$rc)" done - out="$(PATH="$PWD/tools/bin:$shim_bin:/usr/bin:/bin" git push -u origin HEAD 2>&1 || true)" + out="$(PATH="$shim_tools:$shim_bin:/usr/bin:/bin" git push -u origin HEAD 2>&1 || true)" printf '%s' "$out" | grep -q '^REAL-GIT push -u origin HEAD$' \ && pass "git shim allows scoped push" || fail "git shim blocked scoped push" - out="$(PATH="$PWD/tools/bin:$shim_bin:/usr/bin:/bin" gh pr create --base dev --title x --body-file /tmp/body 2>&1 || true)" + out="$(PATH="$shim_tools:$shim_bin:/usr/bin:/bin" gh pr create --base dev --title x --body-file /tmp/body 2>&1 || true)" printf '%s' "$out" | grep -q '^REAL-GH pr create --base dev' \ && pass "gh shim allows PR creation to dev" || fail "gh shim blocked scoped PR creation" - out="$(PATH="$PWD/tools/bin:$shim_bin:/usr/bin:/bin" gh run view 123 --log 2>&1 || true)" + out="$(PATH="$shim_tools:$shim_bin:/usr/bin:/bin" gh run view 123 --log 2>&1 || true)" printf '%s' "$out" | grep -q '^REAL-GH run view 123 --log$' \ && pass "gh shim allows read-only Actions logs" || fail "gh shim blocked Actions logs" - rc=0; PATH="$PWD/tools/bin:$shim_bin:/usr/bin:/bin" gh run rerun 123 --failed >/dev/null 2>&1 || rc=$? + rc=0; PATH="$shim_tools:$shim_bin:/usr/bin:/bin" gh run rerun 123 --failed >/dev/null 2>&1 || rc=$? [ "$rc" -eq 2 ] && pass "gh shim blocks Actions mutation" || fail "gh shim allowed Actions mutation" - rc=0; PATH="$PWD/tools/bin:$shim_bin:/usr/bin:/bin" gh -R uscient/agent-lab pr merge 1 >/dev/null 2>&1 || rc=$? + rc=0; PATH="$shim_tools:$shim_bin:/usr/bin:/bin" gh -R uscient/agent-lab pr merge 1 >/dev/null 2>&1 || rc=$? [ "$rc" -eq 2 ] && pass "gh shim blocks PR mutation after global options" || fail "gh shim missed PR mutation after global options" - rc=0; PATH="$PWD/tools/bin:$shim_bin:/usr/bin:/bin" gh auth status >/dev/null 2>&1 || rc=$? + rc=0; PATH="$shim_tools:$shim_bin:/usr/bin:/bin" gh -R other/repo pr create --base dev >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 2 ] && pass "gh shim blocks cross-repository PR creation" || fail "gh shim allowed cross-repository PR creation" + rc=0; PATH="$shim_tools:$shim_bin:/usr/bin:/bin" gh auth status >/dev/null 2>&1 || rc=$? [ "$rc" -eq 2 ] && pass "gh shim blocks authentication access" || fail "gh shim should block auth access" + rc=0 + AGENT_LAB_SHIM_BRANCH=group/g0-operator-surface \ + PATH="$shim_tools:$shim_bin:/usr/bin:/bin" git rebase origin/flow >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 2 ] && pass "git shim blocks group history rewrite" || fail "git shim allowed group history rewrite" + rc=0 + AGENT_LAB_SHIM_BRANCH=slice/group/g0-operator-surface/cli \ + PATH="$shim_tools:$shim_bin:/usr/bin:/bin" git push --force-with-lease origin HEAD >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 2 ] && pass "git shim blocks program-slice force update" || fail "git shim allowed program-slice force update" + out="$(AGENT_LAB_SHIM_BRANCH=slice/group/g0-operator-surface/cli \ + PATH="$shim_tools:$shim_bin:/usr/bin:/bin" gh pr create \ + --base group/g0-operator-surface --title x --body y 2>&1 || true)" + printf '%s' "$out" | grep -q '^REAL-GH pr create --base group/g0-operator-surface' \ + && pass "gh shim allows matching group-slice PR" || fail "gh shim blocked matching group-slice PR" + rc=0 + AGENT_LAB_SHIM_BRANCH=flow PATH="$shim_tools:$shim_bin:/usr/bin:/bin" \ + git push origin HEAD >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 2 ] && pass "git shim blocks protected flow push" || fail "git shim allowed protected flow push" + rc=0 + AGENT_LAB_SHIM_BRANCH=agent/test/guard PATH="$shim_tools:$shim_bin:/usr/bin:/bin" \ + git -C /tmp/linked-dev commit -m bad >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 2 ] && pass "git shim blocks alternate-worktree mutation" || fail "git shim allowed alternate-worktree mutation" + rc=0 + AGENT_LAB_SHIM_BRANCH=work/demo PATH="$shim_tools:$shim_bin:/usr/bin:/bin" \ + git merge refs/heads/slice/demo/one >/dev/null 2>&1 || rc=$? + [ "$rc" -eq 2 ] && pass "git shim blocks alternate slice-ref integration" || fail "git shim allowed alternate slice-ref integration" else skip "tools/bin/git shim missing" fi diff --git a/tests/dev/ci-workflow-cases.sh b/tests/dev/ci-workflow-cases.sh index cd9a393..402b87d 100755 --- a/tests/dev/ci-workflow-cases.sh +++ b/tests/dev/ci-workflow-cases.sh @@ -6,8 +6,8 @@ set -euo pipefail # implement a general YAML parser. repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." >/dev/null 2>&1 && pwd)" -ci="$repo_root/.github/workflows/ci.yml" -codeql="$repo_root/.github/workflows/codeql.yml" +ci="${AGENT_LAB_CI_WORKFLOW:-$repo_root/.github/workflows/ci.yml}" +codeql="${AGENT_LAB_CODEQL_WORKFLOW:-$repo_root/.github/workflows/codeql.yml}" ci_fast="$repo_root/scripts/dev/ci-fast" failures=0 @@ -102,18 +102,23 @@ if [ "$(sed -n '1p' "$codeql")" = "name: CodeQL" ]; then else fail "CodeQL workflow keeps its stable check name" fi +if job_block "$codeql" analyze | grep -Fq ' name: CodeQL'; then + pass "CodeQL job keeps its stable rollup name" +else + fail "CodeQL job keeps its stable rollup name" +fi -if [ "$(grep -Fxc ' branches: [dev, master, main]' "$ci")" -eq 1 ] && - [ "$(grep -Fxc " branches: [dev, master, main, 'work/**']" "$ci")" -eq 1 ]; then - pass "CI runs for protected pushes and workstream pull requests" +if [ "$(grep -Fxc ' branches: [dev, flow, master, main]' "$ci")" -eq 1 ] && + [ "$(grep -Fxc " branches: [dev, flow, master, main, 'work/**', 'group/**']" "$ci")" -eq 1 ]; then + pass "CI runs for flow pushes and every authorized PR base" else - fail "CI runs for protected pushes and workstream pull requests" + fail "CI runs for flow pushes and every authorized PR base" fi -if [ "$(grep -Fxc ' branches: [dev, master, main]' "$codeql")" -eq 1 ] && - [ "$(grep -Fxc " branches: [dev, master, main, 'work/**']" "$codeql")" -eq 1 ]; then - pass "CodeQL runs for protected pushes and workstream pull requests" +if [ "$(grep -Fxc ' branches: [dev, flow, master, main]' "$codeql")" -eq 1 ] && + [ "$(grep -Fxc " branches: [dev, flow, master, main, 'work/**', 'group/**']" "$codeql")" -eq 1 ]; then + pass "CodeQL runs for flow pushes and every authorized PR base" else - fail "CodeQL runs for protected pushes and workstream pull requests" + fail "CodeQL runs for flow pushes and every authorized PR base" fi if grep -Fxq ' merge_group:' "$ci" && grep -Fxq ' merge_group:' "$codeql"; then @@ -144,6 +149,10 @@ require_job_text fast ' name: Fast' "fast job has a stable display name" require_job_text fast ' timeout-minutes: 15' "fast job has a bounded runtime" require_job_text fast ' diff-base: ${{ steps.diff-base.outputs.base }}' \ "fast job publishes its immutable base to the aggregate" +require_job_text fast ' tested-head: ${{ steps.diff-base.outputs.head }}' \ + "fast job publishes its tested head to the aggregate" +require_job_text fast ' classification: ${{ steps.fast-gate.outputs.classification }}' \ + "fast job publishes its result classification" require_job_text fast './scripts/dev/ci-fast' \ "fast job exposes the canonical local replay command" require_job_text fast 'git merge-base --is-ancestor "$base" HEAD' \ @@ -152,6 +161,16 @@ require_job_text fast '^([0-9a-f]{40}|[0-9a-f]{64})$' \ "fast job validates the event SHA grammar" require_job_text fast 'merge_group) base="$MERGE_GROUP_BASE_SHA"' \ "fast job resolves the immutable merge-group base" +require_job_text fast '[ "$head" = "$dev_head" ]' \ + "fast job permits only the exact zero-base flow bootstrap neighbor" +require_job_text fast 'cross-repository PRs are not accepted' \ + "fast job rejects cross-repository evidence" +require_job_text fast 'valid_group()' \ + "fast job bounds the reserved group namespace" +require_job_text fast '[ "$PR_HEAD_REF" = "group/$group_name" ] && valid_group "$group_name"' \ + "fast job enforces group to flow topology" +require_job_text fast '[[ "$PR_HEAD_REF" =~ ^slice/group/$group_name/$component$ ]]' \ + "fast job enforces matching group-slice topology" if [ -x "$ci_fast" ] && awk ' /scripts\/dev\/cue-tool provision/ { cue=NR } @@ -166,12 +185,20 @@ fi require_job_text static ' name: Static' "static job has a stable display name" require_job_text static ' timeout-minutes: 15' "static job has a bounded runtime" +require_job_text static ' tested-head: ${{ steps.identity.outputs.head }}' \ + "static job publishes its tested head" +require_job_text static ' classification: ${{ steps.static-gate.outputs.classification }}' \ + "static job publishes its result classification" require_job_text static './tools/validate.sh --strict' \ "static job exposes the canonical local replay command" require_job_text docker ' name: Docker security' \ "Docker job has a stable display name" require_job_text docker ' timeout-minutes: 45' "Docker job has a bounded runtime" +require_job_text docker ' tested-head: ${{ steps.identity.outputs.head }}' \ + "Docker job publishes its tested head" +require_job_text docker ' classification: ${{ steps.docker-gate.outputs.classification }}' \ + "Docker job publishes its result classification" require_job_text docker './scripts/dev/docker-gate' \ "Docker job exposes the canonical local replay command" require_job_text docker 'docker/build-push-action@' \ @@ -208,6 +235,8 @@ require_job_text required-gates ' needs: [fast, static, docker]' \ "aggregate job names every required worker" require_job_text required-gates 'CI_NEEDS_JSON: ${{ toJSON(needs) }}' \ "aggregate job passes structured dependency state to the reducer" +require_job_text required-gates 'CI_EXPECTED_HEAD: ${{ github.sha }}' \ + "aggregate job binds every worker to the event head" require_job_text required-gates './scripts/dev/required-gates' \ "aggregate job uses the versioned fail-closed reducer" @@ -236,6 +265,65 @@ if ! grep -Fq 'git rev-parse HEAD^' "$ci"; then else fail "diff-base selection has no implicit HEAD fallback" fi +if ! grep -Eq '^[[:space:]]*(paths|paths-ignore):' "$ci" "$codeql" && + ! grep -Fq 'continue-on-error:' "$ci" "$codeql"; then + pass "required workflow scope has no path filter or continue-on-error escape" +else + fail "required workflow scope has no path filter or continue-on-error escape" +fi +if [ "$(grep -Fxc ' 125) classification=infrastructure ;;' "$ci")" -eq 3 ] && + [ "$(grep -Fc 'classification=%s\n' "$ci")" -eq 3 ]; then + pass "every worker preserves assertion and infrastructure result classes" +else + fail "every worker preserves assertion and infrastructure result classes" +fi + +if [ "${CI_WORKFLOW_MUTATION_PROBE:-0}" != 1 ]; then + run_ci_mutant() { + local name="$1" expression="$2" target="${3:-ci}" + local mutant="$work_dir/$name.yml" + local source="$ci" rc=0 + if [ "$target" = codeql ]; then + source="$codeql" + fi + sed "$expression" "$source" > "$mutant" + if cmp -s "$source" "$mutant"; then + fail "$name mutation is calibrated" + return + fi + if [ "$target" = codeql ]; then + CI_WORKFLOW_MUTATION_PROBE=1 AGENT_LAB_CI_WORKFLOW="$ci" \ + AGENT_LAB_CODEQL_WORKFLOW="$mutant" bash "$0" > "$work_dir/$name.out" 2>&1 || rc=$? + else + CI_WORKFLOW_MUTATION_PROBE=1 AGENT_LAB_CI_WORKFLOW="$mutant" \ + AGENT_LAB_CODEQL_WORKFLOW="$codeql" bash "$0" > "$work_dir/$name.out" 2>&1 || rc=$? + fi + if [ "$rc" -eq 1 ] && grep -Eq 'SUMMARY failures=[1-9][0-9]*' "$work_dir/$name.out"; then + pass "$name mutation turns the workflow contract RED" + else + fail "$name mutation turns the workflow contract RED (rc=$rc)" + fi + } + + work_dir="$(mktemp -d)" + trap 'find "$work_dir" -depth -delete >/dev/null 2>&1 || true' EXIT + run_ci_mutant omit-flow-trigger \ + "s/branches: \[dev, flow, master, main\]/branches: [dev, master, main]/" + run_ci_mutant omit-group-trigger \ + "s/, 'group\/\*\*'//" + run_ci_mutant allow-continue-on-error \ + '/id: fast-gate/a\ continue-on-error: true' + run_ci_mutant drop-required-worker \ + 's/needs: \[fast, static, docker\]/needs: [fast, static]/' + run_ci_mutant erase-result-classification \ + '0,/125) classification=infrastructure/s//125) classification=assertion-failure/' + run_ci_mutant weaken-flow-bootstrap \ + 's/\[ "$head" = "$dev_head" \]/[ "$head" != "$dev_head" ]/' + run_ci_mutant bypass-reducer \ + 's#run: ./scripts/dev/required-gates#run: true#' + run_ci_mutant codeql-omit-flow \ + "s/branches: \[dev, flow, master, main\]/branches: [dev, master, main]/" codeql +fi printf 'SUMMARY failures=%s\n' "$failures" [ "$failures" -eq 0 ] diff --git a/tests/dev/required-gates-cases.sh b/tests/dev/required-gates-cases.sh index 226ba7b..5e61cac 100755 --- a/tests/dev/required-gates-cases.sh +++ b/tests/dev/required-gates-cases.sh @@ -7,6 +7,7 @@ manifest="$repo_root/tests/security/ci.manifest" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT failures=0 +tested_head="2222222222222222222222222222222222222222" pass() { printf 'PASS %s\n' "$1" @@ -17,12 +18,33 @@ fail() { failures=$((failures + 1)) } +attach_evidence() { + local json="$1" prepared="" + prepared="$(printf '%s' "$json" | jq -c --arg head "$tested_head" ' + if type != "object" then . else + with_entries( + if (.value | type) == "object" then + .value.outputs = + ((if (.value.outputs | type) == "object" then .value.outputs else {} end) + + {"tested-head": $head, + "classification": + (if .value.result == "success" then "success" + elif .value.result == "failure" then "assertion-failure" + else "infrastructure" end)}) + else . end) + end + ' 2>/dev/null)" || prepared="$json" + printf '%s' "$prepared" +} + run_reducer() { local json="$1" summary_path="${2:-}" + json="$(attach_evidence "$json")" if [ -n "$summary_path" ]; then - CI_NEEDS_JSON="$json" GITHUB_STEP_SUMMARY="$summary_path" "$reducer" + CI_NEEDS_JSON="$json" CI_EXPECTED_HEAD="$tested_head" \ + GITHUB_STEP_SUMMARY="$summary_path" "$reducer" else - CI_NEEDS_JSON="$json" "$reducer" + CI_NEEDS_JSON="$json" CI_EXPECTED_HEAD="$tested_head" "$reducer" fi } @@ -41,6 +63,7 @@ capture_reducer() { local case_id="$1" json="$2" summary_path="${3:-}" manifest_path="${4:-}" local path_override="${5:-$PATH}" local -a args=() + json="$(attach_evidence "$json")" CAPTURE_STDOUT="$work/$case_id.stdout" CAPTURE_STDERR="$work/$case_id.stderr" @@ -50,11 +73,12 @@ capture_reducer() { fi if [ -n "$summary_path" ]; then - env PATH="$path_override" CI_NEEDS_JSON="$json" \ + env PATH="$path_override" CI_NEEDS_JSON="$json" CI_EXPECTED_HEAD="$tested_head" \ GITHUB_STEP_SUMMARY="$summary_path" \ "$reducer" "${args[@]}" > "$CAPTURE_STDOUT" 2> "$CAPTURE_STDERR" || CAPTURE_RC=$? else env -u GITHUB_STEP_SUMMARY PATH="$path_override" CI_NEEDS_JSON="$json" \ + CI_EXPECTED_HEAD="$tested_head" \ "$reducer" "${args[@]}" > "$CAPTURE_STDOUT" 2> "$CAPTURE_STDERR" || CAPTURE_RC=$? fi } @@ -162,12 +186,58 @@ fi expect_rc 1 "failure blocks the required gate" \ '{"fast":{"result":"failure"},"static":{"result":"success"},"docker":{"result":"success"}}' \ "REQUIRED GATES FAIL" -expect_rc 1 "cancelled blocks the required gate" \ +expect_rc 125 "cancelled is infrastructure uncertainty" \ '{"fast":{"result":"success"},"static":{"result":"cancelled"},"docker":{"result":"success"}}' \ - "REQUIRED GATES FAIL" -expect_rc 1 "skipped blocks the required gate" \ + "REQUIRED GATES INFRA" +expect_rc 125 "skipped is infrastructure uncertainty" \ '{"fast":{"result":"success"},"static":{"result":"success"},"docker":{"result":"skipped"}}' \ - "REQUIRED GATES FAIL" + "REQUIRED GATES INFRA" + +expect_raw_rc() { + local expected="$1" name="$2" json="$3" expected_head="$4" marker="$5" rc=0 out + out="$(CI_NEEDS_JSON="$json" CI_EXPECTED_HEAD="$expected_head" "$reducer" 2>&1)" || rc=$? + if [ "$rc" -eq "$expected" ] && printf '%s\n' "$out" | grep -Fq "$marker"; then + pass "$name" + else + fail "$name (rc=$rc, expected=$expected, marker=$marker)" + printf '%s\n' "$out" + fi +} + +evidenced_success="$(attach_evidence "$success_json")" +stale_head_json="$(printf '%s' "$evidenced_success" | jq -c \ + '.static.outputs["tested-head"]="3333333333333333333333333333333333333333"')" +expect_raw_rc 125 "stale worker head is infrastructure uncertainty" \ + "$stale_head_json" "$tested_head" "REQUIRED GATES INFRA" +missing_class_json="$(printf '%s' "$evidenced_success" | jq -c \ + 'del(.docker.outputs.classification)')" +expect_raw_rc 125 "missing worker classification is infrastructure uncertainty" \ + "$missing_class_json" "$tested_head" "REQUIRED GATES INFRA" +infra_failure_json="$(printf '%s' "$evidenced_success" | jq -c \ + '.docker.result="failure" | .docker.outputs.classification="infrastructure"')" +expect_raw_rc 125 "worker infrastructure failure retains exit 125" \ + "$infra_failure_json" "$tested_head" "REQUIRED GATES INFRA" +mixed_failure_json="$(printf '%s' "$infra_failure_json" | jq -c \ + '.static.result="failure" | .static.outputs.classification="assertion-failure"')" +expect_raw_rc 125 "infrastructure takes precedence over mixed assertion failure" \ + "$mixed_failure_json" "$tested_head" "REQUIRED GATES INFRA" +forged_success_json="$(printf '%s' "$evidenced_success" | jq -c \ + '.fast.outputs.classification="assertion-failure"')" +expect_raw_rc 125 "success with forged failure classification is not green" \ + "$forged_success_json" "$tested_head" "REQUIRED GATES INFRA" + +head_mutant="$work/required-gates-head-mutant" +sed 's/if \[ "$gate_head" != "$CI_EXPECTED_HEAD" \]; then/if false; then/' \ + "$reducer" > "$head_mutant" +chmod +x "$head_mutant" +head_mutant_rc=0 +CI_NEEDS_JSON="$stale_head_json" CI_EXPECTED_HEAD="$tested_head" \ + "$head_mutant" --manifest "$manifest" >/dev/null 2>&1 || head_mutant_rc=$? +if ! cmp -s "$reducer" "$head_mutant" && [ "$head_mutant_rc" -eq 0 ]; then + pass "tested-head sensitivity mutation turns the stale-head case RED" +else + fail "tested-head sensitivity mutation turns the stale-head case RED" +fi expect_rc 125 "missing required job is infrastructure failure" \ '{"fast":{"result":"success"},"static":{"result":"success"}}' \ @@ -205,6 +275,18 @@ else printf '%s\n' "$unset_out" fi +unset_head_rc=0 +unset_head_out="$(env -u CI_EXPECTED_HEAD CI_NEEDS_JSON="$evidenced_success" \ + "$reducer" 2>&1)" || unset_head_rc=$? +if [ "$unset_head_rc" -eq 125 ] && + printf '%s\n' "$unset_head_out" | grep -Fq 'REQUIRED GATES INFRA' && + printf '%s\n' "$unset_head_out" | grep -Fq '`CI_EXPECTED_HEAD` is unset.'; then + pass "unset CI_EXPECTED_HEAD is infrastructure failure" +else + fail "unset CI_EXPECTED_HEAD is infrastructure failure (rc=$unset_head_rc)" + printf '%s\n' "$unset_head_out" +fi + empty_file="$work/empty" : > "$empty_file" base40="1111111111111111111111111111111111111111" diff --git a/tests/dev/security-gate-cases.sh b/tests/dev/security-gate-cases.sh index 15eccb6..4c13100 100644 --- a/tests/dev/security-gate-cases.sh +++ b/tests/dev/security-gate-cases.sh @@ -222,7 +222,7 @@ gate-concurrency tests/dev/security-gate-concurrency-cases.sh SUMMARY failures=0 ci-workflow-contract tests/dev/ci-workflow-cases.sh SUMMARY failures=0 required-gates-contract tests/dev/required-gates-cases.sh SUMMARY failures=0 guard-diff tests/dev/guard-diff-cases.sh SUMMARY failures=0 -workflow-metadata tests/dev/workflow-check-cases.sh SUMMARY pass=114 fail=0 +workflow-metadata tests/dev/workflow-check-cases.sh SUMMARY pass=134 fail=0 coordination tests/dev/coord-cases.sh SUMMARY assertions=58 expected=58 failures=0 docker-harness-contract tests/dev/docker-harness-cases.sh SUMMARY failures=0 guard-command tests/guard/pretooluse-cases.sh SUMMARY failures=0 @@ -244,7 +244,7 @@ dns-contract-unit tests/egress/dns-contract-cases.sh SUMMARY failures=0 wrapper-context tests/wrap-image/context-cases.sh SUMMARY failures=0 adapter-idempotence tests/agent/render-adapters-idempotence.sh PASS generated adapters match tracked files and are idempotent serena-config tests/serena/config-cases.sh SUMMARY failures=0 -policy-probe tests/agent/policy-verify.sh SUMMARY pass=46 fail=0 skip=0 +policy-probe tests/agent/policy-verify.sh SUMMARY pass=67 fail=0 skip=0 containment-static tools/containment-lint.sh containment-lint: 0 fail, 0 warn EOF actual_suites="$work/actual-fast-suites" diff --git a/tests/dev/workflow-check-cases.sh b/tests/dev/workflow-check-cases.sh index 45870db..12ebd46 100755 --- a/tests/dev/workflow-check-cases.sh +++ b/tests/dev/workflow-check-cases.sh @@ -97,12 +97,14 @@ expect_usage "unknown command fails closed" unknown expect_usage "missing commit subject fails closed" commit expect_usage "missing PR body path fails closed" pr-body expect_usage "missing PR base fails closed" pr-base +expect_usage "missing PR route fails closed" pr-route expect_usage "branch rejects extra arguments" branch xor/dev-lane extra expect_usage "commit rejects extra arguments" commit "Implement workflow checks" extra expect_usage "commits rejects extra arguments" commits origin/dev extra expect_usage "PR title rejects extra arguments" pr-title "Implement workflow checks" extra expect_usage "PR body rejects extra arguments" pr-body body.md extra expect_usage "PR base rejects extra arguments" pr-base dev extra +expect_usage "PR route rejects extra arguments" pr-route flow dev extra expect_usage "all rejects extra arguments" all origin/dev extra echo "== commit subjects ==" @@ -143,12 +145,20 @@ expect_ok "existing work-now branch remains policy-valid" branch work/now expect_ok "uppercase work branch remains policy-valid" branch Xor/Dev-Lane expect_ok "manual underscore branch remains policy-valid" branch xor/dev_lane expect_reject "protected dev is rejected" "protected" branch dev +expect_reject "protected flow is rejected" "protected" branch flow expect_reject "protected master is rejected" "protected" branch master expect_reject "protected main is rejected" "protected" branch main expect_reject "empty branch is rejected" "detached or empty" branch "" expect_reject "invalid Git ref is rejected" "invalid Git branch" branch "bad branch" expect_reject "generic branch name is rejected" "generic" branch work expect_reject "Git previous-branch syntax is rejected" "magic syntax" branch '@{-1}' +expect_ok "group branch is accepted" branch group/g0-operator-surface +expect_ok "group slice branch is accepted" branch slice/group/g0-operator-surface/cli +expect_ok "legacy slice branch is accepted" branch slice/demo/one +expect_reject "invalid reserved group is rejected" "invalid reserved" branch group/not-valid +expect_reject "overlong reserved group is rejected" "invalid reserved" branch \ + group/g0-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +expect_reject "invalid reserved slice is rejected" "invalid reserved" branch slice/group/not-valid/cli echo "== pull request metadata ==" expect_ok "outcome PR title is accepted" pr-title \ @@ -172,9 +182,18 @@ expect_reject "merge boilerplate PR title is rejected" "merge boilerplate" pr-ti expect_reject "task-only PR title is rejected" "task-only" pr-title \ "Task 42 changes" expect_ok "dev PR base is accepted" pr-base dev -expect_reject "master PR base is rejected" "PR base must be dev" pr-base master -expect_reject "main PR base is rejected" "PR base must be dev" pr-base main -expect_reject "arbitrary PR base is rejected" "PR base must be dev" pr-base release +expect_reject "master PR base is rejected" "must be dev" pr-base master +expect_reject "main PR base is rejected" "must be dev" pr-base main +expect_reject "arbitrary PR base is rejected" "must be dev" pr-base release +expect_ok "flow final PR route is accepted" pr-route flow dev +expect_ok "group PR route is accepted" pr-route group/g0-operator-surface flow +expect_ok "group slice PR route is accepted" pr-route \ + slice/group/g0-operator-surface/cli group/g0-operator-surface +expect_ok "legacy slice PR route is accepted" pr-route slice/demo/one work/demo +expect_ok "workstream final PR route is accepted" pr-route work/demo dev +expect_reject "group cannot skip flow" "must be flow" pr-route group/g0-operator-surface dev +expect_reject "group slice cannot skip parent" "must be group/g0-operator-surface" pr-route \ + slice/group/g0-operator-surface/cli flow good_body="$work/good-body.md" cat > "$good_body" <<'EOF' @@ -548,6 +567,41 @@ else printf '%s\n' "$checker_out" fi +derived_repo="$work/derived-base-repo" +make_repo "$derived_repo" +git -C "$derived_repo" switch -q dev +commit_file "$derived_repo" "Advance flow integration fixture" +git -C "$derived_repo" update-ref refs/remotes/origin/flow HEAD +git -C "$derived_repo" switch -qc group/g0-operator-surface +commit_file "$derived_repo" "Implement operator surface fixture" +run_in_repo "$derived_repo" commits origin/flow +if [ "$checker_rc" -eq 0 ] && printf '%s\n' "$checker_out" | grep -Fq "commits=1"; then + pass "group commit range derives origin/flow" +else + fail "group commit range derives origin/flow (rc=$checker_rc)" +fi +run_in_repo "$derived_repo" commits origin/dev +if [ "$checker_rc" -eq 1 ] && printf '%s\n' "$checker_out" | grep -Fq "must resolve to origin/flow"; then + pass "group cannot narrow or redirect its flow base" +else + fail "group cannot narrow or redirect its flow base (rc=$checker_rc)" +fi +git -C "$derived_repo" update-ref refs/remotes/origin/group/g0-operator-surface HEAD +git -C "$derived_repo" switch -qc slice/group/g0-operator-surface/cli +commit_file "$derived_repo" "Implement group slice fixture" +run_in_repo "$derived_repo" commits origin/group/g0-operator-surface +if [ "$checker_rc" -eq 0 ] && printf '%s\n' "$checker_out" | grep -Fq "commits=1"; then + pass "group slice commit range derives its matching group" +else + fail "group slice commit range derives its matching group (rc=$checker_rc)" +fi +run_in_repo "$derived_repo" commits origin/flow +if [ "$checker_rc" -eq 1 ] && printf '%s\n' "$checker_out" | grep -Fq "must resolve to origin/group/g0-operator-surface"; then + pass "group slice cannot skip its matching group base" +else + fail "group slice cannot skip its matching group base (rc=$checker_rc)" +fi + run_in_repo "$valid_repo" commits HEAD if [ "$checker_rc" -eq 1 ] && printf '%s\n' "$checker_out" | grep -Fq "must resolve to origin/dev"; then pass "caller cannot exclude introduced commits with a HEAD base" @@ -805,7 +859,7 @@ else printf '%s\n' "$checker_out" fi -expected_passes=114 +expected_passes=134 if [ "$passes" -ne "$expected_passes" ]; then fail "contract executed the exact expected assertions ($passes/$expected_passes)" fi diff --git a/tests/dev/workstream-cases.sh b/tests/dev/workstream-cases.sh index a7ac05d..7ca6ec1 100755 --- a/tests/dev/workstream-cases.sh +++ b/tests/dev/workstream-cases.sh @@ -8,6 +8,7 @@ trap 'find "$work" -depth -delete >/dev/null 2>&1 || true' EXIT failures=0 pass() { printf 'PASS %s\n' "$1"; } fail() { printf 'FAIL %s\n' "$1"; failures=$((failures + 1)); } +infra() { printf 'INFRA %s\n' "$1" >&2; exit 125; } if [ -x "$command_path" ]; then pass "workstream command exists and is executable" @@ -20,30 +21,55 @@ fi fake_bin="$work/bin" mkdir -p "$fake_bin" gh_log="$work/gh.log" +merged_marker="$work/merged" cat > "$fake_bin/gh" <<'EOF' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >> "$WORKSTREAM_GH_LOG" case "$1 $2" in - "pr view") printf '%s\n' "$WORKSTREAM_PR_JSON" ;; - "pr merge") exit 0 ;; + "repo view") printf '%s\n' 'uscient/agent-lab' ;; + "pr view") + if [[ "$*" == *'--json state --jq .state'* ]] && [ -f "$WORKSTREAM_MERGED_MARKER" ]; then + printf '%s\n' MERGED + else + printf '%s\n' "$WORKSTREAM_PR_JSON" + fi + ;; + "pr merge") : > "$WORKSTREAM_MERGED_MARKER" ;; "pr create") exit 0 ;; + "api repos/uscient/agent-lab/commits/"*) + printf '%s\n' "$WORKSTREAM_BASE_CHECKS_JSON" + ;; + "api repos/uscient/agent-lab/compare/"*) + printf '%s\n' "${WORKSTREAM_COMPARE_STATUS:-ahead}" + ;; *) exit 99 ;; esac EOF chmod +x "$fake_bin/gh" -valid_json='{"number":17,"state":"OPEN","isDraft":false,"baseRefName":"work/demo","headRefName":"slice/demo/format","headRefOid":"0123456789abcdef0123456789abcdef01234567","mergeStateStatus":"CLEAN","reviewDecision":"","statusCheckRollup":[{"name":"Fast","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"Required gates","status":"COMPLETED","conclusion":"SUCCESS"}]}' +base_oid='1111111111111111111111111111111111111111' +head_oid='0123456789abcdef0123456789abcdef01234567' +valid_json='{"number":17,"state":"OPEN","isDraft":false,"baseRefName":"work/demo","baseRefOid":"1111111111111111111111111111111111111111","headRefName":"slice/demo/format","headRefOid":"0123456789abcdef0123456789abcdef01234567","isCrossRepository":false,"headRepository":{"nameWithOwner":"uscient/agent-lab"},"mergeStateStatus":"CLEAN","reviewDecision":"APPROVED","statusCheckRollup":[{"name":"Fast","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"Required gates","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"CodeQL","status":"COMPLETED","conclusion":"SUCCESS"}]}' +valid_base_checks='{"check_runs":[{"name":"Required gates","status":"completed","conclusion":"success"},{"name":"CodeQL","status":"completed","conclusion":"success"}]}' route_fixture="$work/route" mkdir -p "$route_fixture/scripts/dev" -cp "$command_path" "$route_fixture/scripts/dev/workstream" +sed "s#/usr/bin/gh#$fake_bin/gh#" "$command_path" > "$route_fixture/scripts/dev/workstream" +chmod +x "$route_fixture/scripts/dev/workstream" git -C "$route_fixture" init -q +git -C "$route_fixture" add scripts/dev/workstream +git -C "$route_fixture" -c user.name=test -c user.email=test@example.invalid \ + commit -qm fixture +fixture_oid="$(git -C "$route_fixture" rev-parse HEAD)" +printf 'scripts/dev/workstream-*\n' > "$route_fixture/.git/info/exclude" +git -C "$route_fixture" update-ref refs/heads/slice/demo/format "$fixture_oid" git -C "$route_fixture" symbolic-ref HEAD refs/heads/slice/demo/format : > "$gh_log" if PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + WORKSTREAM_MERGED_MARKER="$merged_marker" \ "$route_fixture/scripts/dev/workstream" pr --title format --body body >/dev/null \ - && grep -Fxq 'pr create --base work/demo --head slice/demo/format --title format --body body' "$gh_log"; then + && grep -Fxq 'pr create --repo github.com/uscient/agent-lab --base work/demo --head slice/demo/format --title format --body body' "$gh_log"; then pass "slice PR routing derives the matching workstream base" else fail "slice PR routing derives the matching workstream base" @@ -54,26 +80,140 @@ if PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ else pass "slice PR routing rejects caller-selected authority" fi +git -C "$route_fixture" symbolic-ref HEAD refs/heads/slice/group/g0-operator-surface/format +: > "$gh_log" +if PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + WORKSTREAM_MERGED_MARKER="$merged_marker" \ + "$route_fixture/scripts/dev/workstream" pr --title format --body body >/dev/null \ + && grep -Fxq 'pr create --repo github.com/uscient/agent-lab --base group/g0-operator-surface --head slice/group/g0-operator-surface/format --title format --body body' "$gh_log"; then + pass "group slice PR routing derives the matching group base" +else + fail "group slice PR routing derives the matching group base" +fi +git -C "$route_fixture" symbolic-ref HEAD refs/heads/group/g0-operator-surface +: > "$gh_log" +if PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + WORKSTREAM_MERGED_MARKER="$merged_marker" \ + "$route_fixture/scripts/dev/workstream" group-pr --title complete --body body >/dev/null \ + && grep -Fxq 'pr create --repo github.com/uscient/agent-lab --base flow --head group/g0-operator-surface --draft --title complete --body body' "$gh_log"; then + pass "group PR routing is fixed to flow and remains draft" +else + fail "group PR routing is fixed to flow and remains draft" +fi +git -C "$route_fixture" symbolic-ref HEAD \ + refs/heads/group/g0-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +if PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + "$route_fixture/scripts/dev/workstream" group-pr --title bad --body body >/dev/null 2>&1; then + fail "overlong group cannot enter the integration route" +else + pass "overlong group cannot enter the integration route" +fi git -C "$route_fixture" symbolic-ref HEAD refs/heads/work/demo : > "$gh_log" if PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + WORKSTREAM_MERGED_MARKER="$merged_marker" \ "$route_fixture/scripts/dev/workstream" final --title complete --body body >/dev/null \ - && grep -Fxq 'pr create --base dev --head work/demo --draft --title complete --body body' "$gh_log"; then + && grep -Fxq 'pr create --repo github.com/uscient/agent-lab --base dev --head work/demo --draft --title complete --body body' "$gh_log"; then pass "final PR routing is fixed to dev and remains draft" else fail "final PR routing is fixed to dev and remains draft" fi +hostile_bin="$work/hostile-bin" +hostile_marker="$work/hostile-gh-ran" +mkdir -p "$hostile_bin" +printf '%s\n' '#!/usr/bin/env bash' ': > "$WORKSTREAM_HOSTILE_MARKER"' 'exit 97' \ + > "$hostile_bin/gh" +chmod +x "$hostile_bin/gh" +: > "$gh_log" +if PATH="$hostile_bin:$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + WORKSTREAM_HOSTILE_MARKER="$hostile_marker" \ + "$route_fixture/scripts/dev/workstream" final --title trusted --body body >/dev/null \ + && [ ! -e "$hostile_marker" ] \ + && grep -Fxq 'pr create --repo github.com/uscient/agent-lab --base dev --head work/demo --draft --title trusted --body body' "$gh_log"; then + pass "trusted GitHub client path defeats PATH injection" +else + fail "trusted GitHub client path defeats PATH injection" +fi +: > "$gh_log" +if GH_REPO=attacker/other GH_HOST=example.invalid \ + PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + "$route_fixture/scripts/dev/workstream" final --title pinned --body body >/dev/null \ + && grep -Fxq 'pr create --repo github.com/uscient/agent-lab --base dev --head work/demo --draft --title pinned --body body' "$gh_log"; then + pass "ambient GitHub repository and host cannot redirect writes" +else + fail "ambient GitHub repository and host cannot redirect writes" +fi + +sync_origin="$work/sync-origin.git" +sync_fixture="$work/sync-fixture" +sync_producer="$work/sync-producer" +sync_trace="$work/sync.trace" +git init --bare -q "$sync_origin" || infra "cannot create sync origin" +mkdir -p "$sync_fixture/scripts/dev" +sed "s#/usr/bin/gh#$fake_bin/gh#" "$command_path" > "$sync_fixture/scripts/dev/workstream" +chmod +x "$sync_fixture/scripts/dev/workstream" +git -C "$sync_fixture" init -q || infra "cannot create sync fixture" +git -C "$sync_fixture" add scripts/dev/workstream +git -C "$sync_fixture" -c user.name=test -c user.email=test@example.invalid \ + commit -qm base || infra "cannot commit sync fixture" +git -C "$sync_fixture" branch flow +git -C "$sync_fixture" branch group/g0-operator-surface +git -C "$sync_fixture" remote add origin "$sync_origin" +git -C "$sync_fixture" push -q origin flow group/g0-operator-surface \ + || infra "cannot seed sync origin" +git -C "$sync_fixture" switch -q group/g0-operator-surface +git clone -q --branch group/g0-operator-surface "$sync_origin" "$sync_producer" \ + || infra "cannot clone sync producer" +git -C "$sync_producer" switch -q -c slice/group/g0-operator-surface/accepted +git -C "$sync_producer" -c user.name=test -c user.email=test@example.invalid \ + commit --allow-empty -qm slice || infra "cannot commit sync slice" +git -C "$sync_producer" switch -q group/g0-operator-surface +git -C "$sync_producer" -c user.name=test -c user.email=test@example.invalid \ + merge --no-ff -qm 'Merge accepted slice' slice/group/g0-operator-surface/accepted \ + || infra "cannot create accepted merge fixture" +accepted_oid="$(git -C "$sync_producer" rev-parse HEAD)" +git -C "$sync_producer" push -q origin group/g0-operator-surface \ + || infra "cannot publish accepted merge fixture" +if GIT_TRACE="$sync_trace" PATH="$hostile_bin:$fake_bin:/usr/bin:/bin" \ + "$sync_fixture/scripts/dev/workstream" sync >/dev/null 2>&1 \ + && [ "$(git -C "$sync_fixture" rev-parse HEAD)" = "$accepted_oid" ] \ + && grep -Fq 'merge --ff-only origin/group/g0-operator-surface' "$sync_trace" \ + && grep -Fq 'merge --no-ff --no-edit origin/flow' "$sync_trace" \ + && ! grep -Fq 'rebase origin/flow' "$sync_trace"; then + pass "sync recovers accepted remote merges before preserving parent ancestry" +else + fail "sync recovers accepted remote merges before preserving parent ancestry" +fi +git -C "$route_fixture" symbolic-ref HEAD refs/heads/flow +: > "$gh_log" +if PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" \ + WORKSTREAM_MERGED_MARKER="$merged_marker" \ + "$route_fixture/scripts/dev/workstream" final --title complete --body body >/dev/null \ + && grep -Fxq 'pr create --repo github.com/uscient/agent-lab --base dev --head flow --draft --title complete --body body' "$gh_log"; then + pass "program final PR routing is fixed from flow to dev and remains draft" +else + fail "program final PR routing is fixed from flow to dev and remains draft" +fi run_merge() { - local json="$1" candidate="${2:-$command_path}" rc=0 + local json="$1" candidate="${2:-$route_fixture/scripts/dev/workstream}" \ + comparison="${3:-ahead}" base_checks="${4:-$valid_base_checks}" rc=0 base : > "$gh_log" + find "$merged_marker" -maxdepth 0 -delete >/dev/null 2>&1 || true + base="$(printf '%s' "$json" | jq -r .baseRefName)" + git -C "$route_fixture" update-ref "refs/heads/$base" "$fixture_oid" + git -C "$route_fixture" symbolic-ref HEAD "refs/heads/$base" PATH="$fake_bin:/usr/bin:/bin" WORKSTREAM_GH_LOG="$gh_log" WORKSTREAM_PR_JSON="$json" \ + WORKSTREAM_MERGED_MARKER="$merged_marker" WORKSTREAM_COMPARE_STATUS="$comparison" \ + WORKSTREAM_BASE_CHECKS_JSON="$base_checks" \ "$candidate" merge 17 >"$work/stdout" 2>"$work/stderr" || rc=$? return "$rc" } if run_merge "$valid_json" \ - && grep -Fxq 'pr merge 17 --merge --match-head-commit 0123456789abcdef0123456789abcdef01234567' "$gh_log"; then + && grep -Fxq "api repos/uscient/agent-lab/compare/$base_oid...$head_oid --hostname github.com --jq .status" "$gh_log" \ + && grep -Fxq "pr merge 17 --repo github.com/uscient/agent-lab --merge --match-head-commit $head_oid" "$gh_log" \ + && ! grep -Eq -- '--(squash|rebase|delete-branch)' "$gh_log"; then pass "green matching slice PR is merged with its observed head commit" else fail "green matching slice PR is merged with its observed head commit" @@ -94,7 +234,7 @@ for field_case in \ wrong-base) mutated="${valid_json/\"baseRefName\":\"work\/demo\"/$replacement}" ;; wrong-head) mutated="${valid_json/\"headRefName\":\"slice\/demo\/format\"/$replacement}" ;; draft) mutated="${valid_json/\"isDraft\":false/$replacement}" ;; - changes-requested) mutated="${valid_json/\"reviewDecision\":\"\"/$replacement}" ;; + changes-requested) mutated="${valid_json/\"reviewDecision\":\"APPROVED\"/$replacement}" ;; dirty) mutated="${valid_json/\"mergeStateStatus\":\"CLEAN\"/$replacement}" ;; pending) mutated="${valid_json/\"status\":\"COMPLETED\"/$replacement}" ;; failed) mutated="${valid_json/\"conclusion\":\"SUCCESS\"/$replacement}" ;; @@ -109,6 +249,57 @@ for field_case in \ fi done +group_json="$(printf '%s' "$valid_json" | jq -c \ + '.baseRefName="group/g0-operator-surface" | .headRefName="slice/group/g0-operator-surface/format"')" +if run_merge "$group_json"; then + pass "approved current-base group slice integrates through the helper" +else + fail "approved current-base group slice integrates through the helper" +fi + +flow_json="$(printf '%s' "$valid_json" | jq -c \ + '.baseRefName="flow" | .headRefName="group/g0-operator-surface"')" +if run_merge "$flow_json"; then + pass "approved current-base group PR integrates into flow through the helper" +else + fail "approved current-base group PR integrates into flow through the helper" +fi + +missing_base_codeql='{"check_runs":[{"name":"Required gates","status":"completed","conclusion":"success"}]}' +if run_merge "$flow_json" "$route_fixture/scripts/dev/workstream" ahead "$missing_base_codeql"; then + fail "group integration waits for a green CodeQL result on the flow base" +elif ! grep -q '^pr merge ' "$gh_log"; then + pass "group integration waits for a green CodeQL result on the flow base" +else + fail "group integration waits for a green CodeQL result on the flow base" +fi + +for json_case in \ + "wrong-number|$(printf '%s' "$valid_json" | jq -c '.number=18')" \ + "unapproved|$(printf '%s' "$valid_json" | jq -c '.reviewDecision=""')" \ + "cross-repository|$(printf '%s' "$valid_json" | jq -c '.isCrossRepository=true')" \ + "foreign-repository|$(printf '%s' "$valid_json" | jq -c '.headRepository.nameWithOwner="other/repo"')" \ + "invalid-base-oid|$(printf '%s' "$valid_json" | jq -c '.baseRefOid="bad"')" \ + "duplicate-check|$(printf '%s' "$valid_json" | jq -c '.statusCheckRollup += [.statusCheckRollup[0]]')"; do + name="${json_case%%|*}" + mutated="${json_case#*|}" + if run_merge "$mutated"; then + fail "$name metadata blocks integration" + elif ! grep -q '^pr merge ' "$gh_log"; then + pass "$name metadata blocks integration" + else + fail "$name metadata blocks integration" + fi +done + +if run_merge "$valid_json" "$route_fixture/scripts/dev/workstream" diverged; then + fail "head that does not contain the observed base blocks integration" +elif ! grep -q '^pr merge ' "$gh_log"; then + pass "head that does not contain the observed base blocks integration" +else + fail "head that does not contain the observed base blocks integration" +fi + empty_checks="$(printf '%s' "$valid_json" | jq -c '.statusCheckRollup = []')" if run_merge "$empty_checks"; then fail "missing checks block integration" @@ -118,10 +309,19 @@ else fail "missing checks block integration" fi -mutant="$work/mutant/scripts/dev/workstream" -mkdir -p "${mutant%/*}" +missing_codeql="$(printf '%s' "$valid_json" | jq -c \ + '.statusCheckRollup |= map(select(.name != "CodeQL"))')" +if run_merge "$missing_codeql"; then + fail "missing CodeQL PR result blocks integration" +elif ! grep -q '^pr merge ' "$gh_log"; then + pass "missing CodeQL PR result blocks integration" +else + fail "missing CodeQL PR result blocks integration" +fi + +mutant="$route_fixture/scripts/dev/workstream-mutant" sed 's/(all(\.statusCheckRollup\[\]; \.status == "COMPLETED" and \.conclusion == "SUCCESS"))/(true)/' \ - "$command_path" > "$mutant" + "$route_fixture/scripts/dev/workstream" > "$mutant" chmod +x "$mutant" if [ "$(cmp -s "$command_path" "$mutant"; printf '%s' "$?")" -ne 0 ] \ && run_merge "${valid_json/\"conclusion\":\"SUCCESS\"/\"conclusion\":\"FAILURE\"}" "$mutant"; then @@ -130,5 +330,17 @@ else fail "all-checks sensitivity mutation turns RED" fi +squash_mutant="$route_fixture/scripts/dev/workstream-squash-mutant" +sed 's/--merge --match-head-commit/--squash --match-head-commit/' \ + "$route_fixture/scripts/dev/workstream" > "$squash_mutant" +chmod +x "$squash_mutant" +if [ "$(cmp -s "$route_fixture/scripts/dev/workstream" "$squash_mutant"; printf '%s' "$?")" -ne 0 ] \ + && run_merge "$valid_json" "$squash_mutant" \ + && grep -Fq 'pr merge 17 --repo github.com/uscient/agent-lab --squash --match-head-commit' "$gh_log"; then + pass "merge-method sensitivity mutation is observable and turns the exact-command oracle RED" +else + fail "merge-method sensitivity mutation is observable and turns the exact-command oracle RED" +fi + printf 'SUMMARY failures=%s\n' "$failures" [ "$failures" -eq 0 ] diff --git a/tests/guard/pretooluse-cases.sh b/tests/guard/pretooluse-cases.sh index a7cd2b0..d651597 100755 --- a/tests/guard/pretooluse-cases.sh +++ b/tests/guard/pretooluse-cases.sh @@ -134,8 +134,24 @@ expect_cmd allow "lint" './scripts/dev/lint-scripts' expect_cmd allow "read a protected file" 'cat AGENTS.md' expect_cmd allow "grep policy" 'grep -r AGENTS.md policy/' +echo "== allow: branch-derived flow/group/slice routes ==" +set_guard_branch group/g0-operator-surface +expect_cmd allow "group PR targets flow" 'gh pr create --base flow --head group/g0-operator-surface --title x --body y' +expect_cmd allow "group same-branch push" 'git push origin HEAD' +set_guard_branch slice/group/g0-operator-surface/cli +expect_cmd allow "group slice PR matches parent" \ + 'gh pr create --base group/g0-operator-surface --head slice/group/g0-operator-surface/cli --title x --body y' +set_guard_branch slice/demo/one +expect_cmd allow "legacy slice rebase matches parent" 'git rebase origin/work/demo' +expect_cmd allow "legacy slice PR matches parent" \ + 'gh pr create --base work/demo --head slice/demo/one --title x --body y' +set_guard_branch flow +expect_cmd allow "flow final PR targets dev" \ + 'gh pr create --base dev --head flow --title x --body y' +set_guard_branch agent/test/guard + echo "== deny: commit on protected branches (branch backstop) ==" -for protected in dev master main; do +for protected in dev flow master main; do set_guard_branch "$protected" expect_cmd block "git commit on $protected" 'git commit -m "wip"' expect_cmd block "git -C commit on $protected" 'git -C . commit -m "wip"' @@ -165,6 +181,9 @@ expect_cmd block "rebase origin/main" 'git rebase origin/main' expect_cmd block "rebase upstream/dev" 'git rebase upstream/dev' expect_cmd block "merge refs/remotes" 'git merge refs/remotes/origin/main' expect_cmd block "git -C . merge origin" 'git -C . merge origin/main' +expect_cmd block "alternate-worktree commit" 'git -C /tmp/linked-dev commit -m bad' +expect_cmd block "alternate-git-dir merge" 'git --git-dir=/tmp/linked-flow/.git merge feature' +expect_cmd block "direct slice merge" 'git merge slice/demo/one' expect_cmd block "PR create without base" 'gh pr create --title x --body y' expect_cmd block "PR create wrong base" 'gh pr create --base main --title x --body y' expect_cmd block "PR create wrong head" 'gh pr create --base dev --head other --title x --body y' @@ -181,6 +200,51 @@ expect_cmd block "GitHub repo delete" 'gh repo delete uscient/agent-la expect_cmd block "absolute GitHub repo delete" '/usr/bin/gh repo delete uscient/agent-lab --yes' expect_cmd block "GitHub issue close" 'gh issue close 1' expect_cmd block "GitHub global-option PR merge" 'gh --repo uscient/agent-lab pr merge 10' + +echo "== deny: wrong flow/group/slice routes and invalid reserved names ==" +set_guard_branch work/demo +expect_cmd block "workstream remote rebase cannot erase accepted merges" 'git rebase origin/dev' +expect_cmd block "workstream local rebase cannot erase accepted merges" 'git rebase dev' +expect_cmd block "workstream alternate slice ref cannot bypass helper" \ + 'git merge refs/heads/slice/demo/one' +expect_cmd block "workstream commit-id merge cannot bypass helper" \ + 'git merge 0123456789abcdef0123456789abcdef01234567' +expect_cmd block "workstream lease push cannot rewrite history" \ + 'git push --force-with-lease origin HEAD' +set_guard_branch group/g0-operator-surface +expect_cmd block "group alternate slice ref cannot bypass helper" \ + 'git merge refs/heads/slice/group/g0-operator-surface/cli' +expect_cmd block "group remote rebase cannot rewrite history" 'git rebase origin/flow' +expect_cmd block "group local rebase cannot rewrite history" 'git rebase flow' +expect_cmd block "group lease push cannot rewrite history" 'git push --force-with-lease origin HEAD' +expect_cmd block "group cannot rebase on dev" 'git rebase origin/dev' +expect_cmd block "group cannot PR to dev" 'gh pr create --base dev --head group/g0-operator-surface --title x --body y' +set_guard_branch slice/group/g0-operator-surface/cli +expect_cmd block "group slice remote rebase cannot rewrite history" \ + 'git rebase origin/group/g0-operator-surface' +expect_cmd block "group slice local rebase cannot rewrite history" \ + 'git rebase group/g0-operator-surface' +expect_cmd block "group slice lease push cannot rewrite history" \ + 'git push --force-with-lease origin HEAD' +expect_cmd block "group slice cannot rebase on flow" 'git rebase origin/flow' +expect_cmd block "group slice cannot rebase sibling" 'git rebase origin/group/g1-contract-growth' +expect_cmd block "group slice cannot PR to flow" \ + 'gh pr create --base flow --head slice/group/g0-operator-surface/cli --title x --body y' +set_guard_branch slice/demo/one +expect_cmd block "legacy slice cannot rebase on dev" 'git rebase origin/dev' +expect_cmd block "legacy slice cannot PR to dev" \ + 'gh pr create --base dev --head slice/demo/one --title x --body y' +set_guard_branch flow +expect_cmd block "merge on protected flow" 'git merge group/g0-operator-surface' +expect_cmd block "rebase on protected flow" 'git rebase dev' +expect_cmd block "push from protected flow" 'git push origin HEAD' +set_guard_branch group/not-valid +expect_cmd block "invalid reserved group cannot push" 'git push origin HEAD' +expect_cmd block "invalid reserved group cannot create PR" \ + 'gh pr create --base dev --head group/not-valid --title x --body y' +set_guard_branch group/g0-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +expect_cmd block "overlong reserved group cannot push" 'git push origin HEAD' +set_guard_branch agent/test/guard expect_cmd block "GitHub hostname auth" 'gh --hostname github.com auth token' expect_cmd block "GitHub auth access" 'gh auth status' expect_cmd block "Git identity access" 'git config --get user.email' @@ -224,6 +288,9 @@ echo "== protected-path edits (Edit/Write matcher) ==" expect_edit block "edit AGENTS.md (no maint)" 'AGENTS.md' expect_edit block "edit guard (no maint)" 'tools/pretooluse-guard.sh' expect_edit block "edit policy (no maint)" 'policy/deny.patterns' +expect_edit block "edit CI workflow (no maint)" '.github/workflows/ci.yml' +expect_edit block "edit gate reducer (no maint)" 'scripts/dev/required-gates' +expect_edit block "edit gate oracle (no maint)" 'tests/dev/required-gates-cases.sh' expect_edit block "edit .codex (no maint)" '.codex/config.toml' expect_edit block "edit Claude MCP registration" '.mcp.json' expect_edit block "edit Serena project config" '.serena/project.yml' @@ -241,7 +308,7 @@ expect_read block "read GitHub credential file" '/home/agent/.config/gh/ho expect_read allow "read normal source file" 'tools/validate.sh' expect_edit allow "edit AGENTS.md (maint=1)" 'AGENTS.md' 1 expect_edit allow "edit .grok (maint=1)" '.grok/config.toml' 1 -expect_edit allow "edit normal file" 'scripts/dev/test' +expect_edit allow "edit normal file" 'scripts/dev/brief' expect_edit allow "edit README" 'README.md' echo "== Serena vendor hook envelopes and semantic mutators ==" diff --git a/tests/security/fast.manifest b/tests/security/fast.manifest index 2f25e28..6ca416f 100644 --- a/tests/security/fast.manifest +++ b/tests/security/fast.manifest @@ -44,7 +44,7 @@ suite gate-concurrency tests/dev/security-gate-concurrency-cases.sh SUMMARY fail suite ci-workflow-contract tests/dev/ci-workflow-cases.sh SUMMARY failures=0 suite required-gates-contract tests/dev/required-gates-cases.sh SUMMARY failures=0 suite guard-diff tests/dev/guard-diff-cases.sh SUMMARY failures=0 -suite workflow-metadata tests/dev/workflow-check-cases.sh SUMMARY pass=114 fail=0 +suite workflow-metadata tests/dev/workflow-check-cases.sh SUMMARY pass=134 fail=0 suite coordination tests/dev/coord-cases.sh SUMMARY assertions=58 expected=58 failures=0 suite docker-harness-contract tests/dev/docker-harness-cases.sh SUMMARY failures=0 suite guard-command tests/guard/pretooluse-cases.sh SUMMARY failures=0 @@ -66,5 +66,5 @@ suite dns-contract-unit tests/egress/dns-contract-cases.sh SUMMARY failures=0 suite wrapper-context tests/wrap-image/context-cases.sh SUMMARY failures=0 suite adapter-idempotence tests/agent/render-adapters-idempotence.sh PASS generated adapters match tracked files and are idempotent suite serena-config tests/serena/config-cases.sh SUMMARY failures=0 -suite policy-probe tests/agent/policy-verify.sh SUMMARY pass=46 fail=0 skip=0 +suite policy-probe tests/agent/policy-verify.sh SUMMARY pass=67 fail=0 skip=0 suite containment-static tools/containment-lint.sh containment-lint: 0 fail, 0 warn diff --git a/tests/serena/config-cases.sh b/tests/serena/config-cases.sh index 9942320..92bc6a0 100755 --- a/tests/serena/config-cases.sh +++ b/tests/serena/config-cases.sh @@ -31,16 +31,117 @@ require_absent() { fi } -require_text .serena/project.yml 'project_name: "agent-lab-dev"' \ +top_level_line_is_exact() { + local file="$1" key="$2" expected="$3" key_count line_count + key_count="$(awk -v prefix="$key:" \ + 'index($0, prefix) == 1 { count++ } END { print count + 0 }' \ + "$file")" + line_count="$(grep -Fxc -- "$expected" "$file" || true)" + [ "$key_count" -eq 1 ] && [ "$line_count" -eq 1 ] +} + +require_top_level_line() { + local file="$1" key="$2" expected="$3" label="$4" + if top_level_line_is_exact "$repo_root/$file" "$key" "$expected"; then + pass "$label" + else + fail "$label" + fi +} + +project_indented_block() { + local file="$1" key="$2" + awk -v key="$key" ' + index($0, key ":") == 1 { + count++ + capture = 1 + next + } + capture && /^[^[:space:]]/ { capture = 0 } + capture { print } + END { if (count != 1) exit 1 } + ' "$file" +} + +project_block_contains() { + local file="$1" key="$2" expected="$3" block + block="$(project_indented_block "$file" "$key")" && + [[ "$block" == *"$expected"* ]] +} + +require_project_block_text() { + local key="$1" expected="$2" label="$3" + if project_block_contains \ + "$repo_root/.serena/project.yml" "$key" "$expected"; then + pass "$label" + else + fail "$label" + fi +} + +require_top_level_line .serena/project.yml project_name \ + 'project_name: "agent-lab-dev"' \ "Serena project has an unambiguous logical name" require_text .serena/project.yml 'language_servers:' \ "Serena project uses the current language_servers schema" require_text .serena/project.yml '- bash' \ "Serena project selects the actual Bash language" -require_text .serena/project.yml 'language_backend: LSP' \ +require_top_level_line .serena/project.yml language_backend \ + 'language_backend: LSP' \ "Serena project explicitly selects the LSP backend" -require_text .serena/project.yml 'ls_workspace_folders: ["."]' \ +require_top_level_line .serena/project.yml ignore_all_files_in_gitignore \ + 'ignore_all_files_in_gitignore: true' \ + "Serena keeps ignored local material outside semantic search" +require_top_level_line .serena/project.yml activation_command \ + 'activation_command:' \ + "Serena activation runs no repository command" +require_top_level_line .serena/project.yml ls_workspace_folders \ + 'ls_workspace_folders: ["."]' \ "Serena indexes exactly the Agent Lab project root" +require_project_block_text ls_specific_settings \ + 'bash_language_server_version: "5.6.0"' \ + "Serena project selects the preseeded Bash language server version" +for workflow_guidance in \ + './scripts/dev/brief' \ + './scripts/dev/changed' \ + 'docs/workstreams.md' \ + './scripts/dev/workstream'; do + require_project_block_text initial_prompt "$workflow_guidance" \ + "Serena activation prompt includes $workflow_guidance" +done +require_project_block_text initial_prompt \ + 'Serena does not' \ + "Serena activation prompt disclaims Git and GitHub authority" + +mutant_config="$work/project-mutant.yml" +sed \ + -e 's#bash_language_server_version: "5.6.0"#bash_language_server_version: "9.9.9"#' \ + -e 's#\./scripts/dev/workstream#./scripts/dev/forged#' \ + "$repo_root/.serena/project.yml" > "$mutant_config" +printf '%s\n' \ + '# project_name: "agent-lab-dev"' \ + '# bash_language_server_version: "5.6.0"' \ + '# ./scripts/dev/workstream' \ + 'project_name: "attacker-controlled"' >> "$mutant_config" +if top_level_line_is_exact \ + "$mutant_config" project_name 'project_name: "agent-lab-dev"'; then + fail "Serena config checks reject duplicate-key comment decoys" +else + pass "Serena config checks reject duplicate-key comment decoys" +fi +if project_block_contains \ + "$mutant_config" ls_specific_settings \ + 'bash_language_server_version: "5.6.0"'; then + fail "Serena config checks reject out-of-block version decoys" +else + pass "Serena config checks reject out-of-block version decoys" +fi +if project_block_contains \ + "$mutant_config" initial_prompt './scripts/dev/workstream'; then + fail "Serena config checks reject out-of-block workflow decoys" +else + pass "Serena config checks reject out-of-block workflow decoys" +fi require_text compose.serena.yaml 'network_mode: none' \ "Serena runtime has no network namespace" diff --git a/tools/bin/gh b/tools/bin/gh index eb8c8d7..4546ef6 100755 --- a/tools/bin/gh +++ b/tools/bin/gh @@ -1,25 +1,51 @@ #!/usr/bin/env bash -# PATH shim for `gh` — allows read-only PR and Actions diagnosis plus PR creation to dev while -# blocking authentication, integration, release mutation, and other remote writes. Slice integration -# is available only through scripts/dev/workstream. Defense-in-depth; AGENTS.md is authoritative. +# PATH shim for `gh` — allows read-only PR and Actions diagnosis plus the branch-derived PR-create +# route while blocking authentication, integration, release mutation, and other remote writes. +# Verified intermediate integration is available only through scripts/dev/workstream. set -uo pipefail -self="$(readlink -f "${BASH_SOURCE[0]}")" -selfdir="$(dirname "$self")" real="" -while IFS= read -r cand; do - rcand="$(readlink -f "$cand" 2>/dev/null || echo "$cand")" - [ "$rcand" = "$self" ] && continue - [ "$(dirname "$rcand")" = "$selfdir" ] && continue - real="$cand"; break -done < <(type -aP gh 2>/dev/null) -if [ -z "$real" ]; then - for c in /usr/bin/gh /usr/local/bin/gh; do [ -x "$c" ] && { real="$c"; break; }; done -fi +for c in /usr/bin/gh /usr/local/bin/gh; do [ -x "$c" ] && { real="$c"; break; }; done [ -z "$real" ] && { echo "agent-lab gh-shim: cannot locate the real gh" >&2; exit 127; } +real_git="" +for c in /usr/bin/git /usr/local/bin/git /bin/git; do [ -x "$c" ] && { real_git="$c"; break; }; done +[ -z "$real_git" ] && { echo "agent-lab gh-shim: cannot locate the real git" >&2; exit 127; } block() { echo "BLOCKED by agent-lab policy: $1 (see AGENTS.md — Autonomy boundary)" >&2; exit 2; } +valid_group() { + [ "${#1}" -le 48 ] && [[ "$1" =~ ^[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*$ ]] +} + +expected_pr_base() { + local branch="$1" group="" + case "$branch" in + flow) printf 'dev\n' ;; + work/*) + [[ "$branch" =~ ^work/[a-z0-9][a-z0-9-]{0,47}$ ]] || return 1 + printf 'dev\n' + ;; + group/*) + valid_group "${branch#group/}" || return 1 + printf 'flow\n' + ;; + slice/group/*/*) + [[ "$branch" =~ ^slice/group/([gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*)/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + group="${BASH_REMATCH[1]}" + valid_group "$group" || return 1 + printf 'group/%s\n' "$group" + ;; + slice/*/*) + [[ "$branch" =~ ^slice/([a-z0-9][a-z0-9-]{0,47})/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + printf 'work/%s\n' "${BASH_REMATCH[1]}" + ;; + slice/* | dev | master | main | DETACHED) return 1 ;; + *) printf 'dev\n' ;; + esac +} + args=("$@") n=${#args[@]} i=0 @@ -46,11 +72,10 @@ case "$sub" in case "${args[$((i + 1))]:-}" in list | view | status | checks | diff) ;; create) - branch="$(git symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" - case "$branch" in - dev | master | main | DETACHED) - block "PR creation requires a non-protected work branch" ;; - esac + [ "$i" -eq 0 ] || block "PR creation must target the current repository directly" + branch="$("$real_git" symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" + required_base="$(expected_pr_base "$branch")" \ + || block "PR creation is not authorized from branch $branch" base="" head="" j=$((i + 2)) while [ "$j" -lt "$n" ]; do a="${args[$j]}" @@ -64,11 +89,12 @@ case "$sub" in esac j=$((j + 1)) done - [ "$base" = dev ] || block "PR creation requires explicit --base dev" + [ "$base" = "$required_base" ] \ + || block "PR creation requires explicit --base $required_base from $branch" [ -z "$head" ] || [ "$head" = "$branch" ] \ || block "PR head must be the current work branch" ;; - *) block "PR mutation is forbidden; only reads and create --base dev are allowed" ;; + *) block "PR mutation is forbidden; only reads and the derived create route are allowed" ;; esac ;; run) [ "$i" -eq 0 ] || block "GitHub Actions commands must target the current repository directly" diff --git a/tools/bin/git b/tools/bin/git index 7e7d068..369da79 100755 --- a/tools/bin/git +++ b/tools/bin/git @@ -7,24 +7,49 @@ # Agent Lab's contained-workload runtime (scripts/agent) — that is data plane, governed by containment. set -uo pipefail -self="$(readlink -f "${BASH_SOURCE[0]}")" -selfdir="$(dirname "$self")" real="" -while IFS= read -r cand; do - rcand="$(readlink -f "$cand" 2>/dev/null || echo "$cand")" - [ "$rcand" = "$self" ] && continue - [ "$(dirname "$rcand")" = "$selfdir" ] && continue - real="$cand"; break -done < <(type -aP git 2>/dev/null) -if [ -z "$real" ]; then - for c in /usr/bin/git /usr/local/bin/git /bin/git; do [ -x "$c" ] && { real="$c"; break; }; done -fi +for c in /usr/bin/git /usr/local/bin/git /bin/git; do [ -x "$c" ] && { real="$c"; break; }; done [ -z "$real" ] && { echo "agent-lab git-shim: cannot locate the real git" >&2; exit 127; } block() { echo "BLOCKED by agent-lab policy: $1 (see AGENTS.md — Autonomy boundary)" >&2; exit 2; } +valid_group() { + [ "${#1}" -le 48 ] && [[ "$1" =~ ^[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*$ ]] +} + +expected_rebase_target() { + local branch="$1" + case "$branch" in + work/* | group/* | slice/group/*/*) return 1 ;; + slice/*/*) + [[ "$branch" =~ ^slice/([a-z0-9][a-z0-9-]{0,47})/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + printf 'origin/work/%s\n' "${BASH_REMATCH[1]}" + ;; + slice/* | dev | flow | master | main | DETACHED) return 1 ;; + *) printf 'origin/dev\n' ;; + esac +} + +is_writable_branch() { + local branch="$1" + case "$branch" in + work/*) [[ "$branch" =~ ^work/[a-z0-9][a-z0-9-]{0,47}$ ]] ;; + group/*) valid_group "${branch#group/}" ;; + slice/group/*/*) + [[ "$branch" =~ ^slice/group/([gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*)/[a-z0-9][a-z0-9-]{0,47}$ ]] && + valid_group "${BASH_REMATCH[1]}" + ;; + slice/*/*) + [[ "$branch" =~ ^slice/[a-z0-9][a-z0-9-]{0,47}/[a-z0-9][a-z0-9-]{0,47}$ ]] + ;; + slice/* | dev | flow | master | main | DETACHED) return 1 ;; + *) return 0 ;; + esac +} + # Locate the subcommand, skipping git's global options (some carry a value). -args=("$@"); n=${#args[@]}; i=0; sub="" +args=("$@"); n=${#args[@]}; i=0; sub="" alternate_context=0 while [ "$i" -lt "$n" ]; do a="${args[$i]}" case "$a" in @@ -44,9 +69,11 @@ while [ "$i" -lt "$n" ]; do i=$((i + 2)); continue ;; --config-env=user.name=* | --config-env=user.email=* | --config-env=user.signingkey=* | --config-env=commit.gpgsign=* | --config-env=alias.*) block "Git attribution, signing identity, and alias overrides are forbidden" ;; - -C | --git-dir | --work-tree | --namespace | --super-prefix | --exec-path) - i=$((i + 2)); continue ;; - --git-dir=* | --work-tree=* | --namespace=* | --exec-path=* | -c*) i=$((i + 1)); continue ;; + -C | --git-dir | --work-tree) + alternate_context=1; i=$((i + 2)); continue ;; + -C?* | --git-dir=* | --work-tree=*) alternate_context=1; i=$((i + 1)); continue ;; + --namespace | --super-prefix | --exec-path) i=$((i + 2)); continue ;; + --namespace=* | --exec-path=* | -c*) i=$((i + 1)); continue ;; -*) i=$((i + 1)); continue ;; *) sub="$a"; break ;; esac @@ -54,11 +81,11 @@ done case "$sub" in commit) + [ "$alternate_context" -eq 0 ] \ + || block "commit through an alternate worktree or Git directory is forbidden" branch="$("$real" symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" - case "$branch" in - dev | master | main | DETACHED) - block "commit on a protected branch or detached HEAD is forbidden" ;; - esac + is_writable_branch "$branch" \ + || block "commit on a protected, detached, or invalid reserved branch is forbidden" for a in "${args[@]:$((i + 1))}"; do case "$a" in --author | --author=* | --reset-author) @@ -68,16 +95,15 @@ case "$sub" in push) [ "$i" -eq 0 ] || block "push through an alternate Git working directory is forbidden" branch="$("$real" symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" - case "$branch" in - dev | master | main | DETACHED) - block "push from protected or detached HEAD is forbidden" ;; - esac + is_writable_branch "$branch" \ + || block "push from a protected, detached, or invalid reserved branch is forbidden" positional=() + lease=0 j=$((i + 1)) while [ "$j" -lt "$n" ]; do case "${args[$j]}" in -u | --set-upstream | --porcelain | --progress | --no-progress | --no-verify) ;; - --force-with-lease) ;; + --force-with-lease) lease=1 ;; --force-with-lease=* | --force | -f | --mirror | --delete | -d) block "plain force, deletion, and mirror pushes are forbidden" ;; -*) block "unsupported push option is outside the scoped workflow: ${args[$j]}" ;; @@ -91,26 +117,47 @@ case "$sub" in || block "push is limited to origin" { [ "${positional[1]}" = HEAD ] || [ "${positional[1]}" = "$branch" ]; } \ || block "push target must be the current same-named branch" + case "$branch" in + work/* | group/* | slice/group/*/*) + [ "$lease" -eq 0 ] || block "integration branches retain published history and cannot be force-updated" + ;; + esac ;; pull) block "git pull is forbidden — fetch (where available) + local merge of a LOCAL ref instead" ;; merge | rebase) + [ "$alternate_context" -eq 0 ] \ + || block "integration through an alternate worktree or Git directory is forbidden" + branch="$("$real" symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" + case "$branch" in + dev | flow | master | main | DETACHED) + block "merge and rebase on a protected branch or detached HEAD are forbidden" ;; + esac + case "$branch" in + work/* | group/* | slice/group/*/*) + block "integration branch history is helper-managed; use scripts/dev/workstream sync or merge" + ;; + esac + if [ "$sub" = merge ]; then + for a in "${args[@]:$((i + 1))}"; do + case "$a" in slice/*) block "slice integration requires the verified workstream helper" ;; esac + done + fi if [ "$sub" = rebase ] && [ "$i" -ne 0 ]; then block "remote rebase through an alternate Git working directory is forbidden" fi + expected="$(expected_rebase_target "$branch" 2>/dev/null || true)" j=$((i + 1)) while [ "$j" -lt "$n" ]; do case "${args[$j]}" in origin/* | upstream/* | refs/remotes/*) if [ "$sub" = rebase ] \ - && [ "${args[$j]}" = origin/dev ] \ + && [ -n "$expected" ] \ + && [ "${args[$j]}" = "$expected" ] \ + && [ "$j" -eq $((i + 1)) ] \ && [ "$j" -eq $((n - 1)) ]; then - branch="$("$real" symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" - case "$branch" in - dev | master | main | DETACHED) - block "origin/dev rebase requires a non-protected work branch" ;; - esac + : else - block "remote integration is limited to rebasing the current work branch on origin/dev" + block "remote integration is limited to rebasing on the exact base derived from the current branch" fi ;; esac diff --git a/tools/pretooluse-guard.sh b/tools/pretooluse-guard.sh index fb7c8ae..03309aa 100755 --- a/tools/pretooluse-guard.sh +++ b/tools/pretooluse-guard.sh @@ -213,20 +213,79 @@ load_branch() { [ -n "$branch" ] && return 0 branch="$(git -C "$root" symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" } -is_work_branch() { +valid_group() { + [ "${#1}" -le 48 ] && [[ "$1" =~ ^[gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*$ ]] +} +is_writable_branch() { + load_branch + case "$branch" in + work/*) [[ "$branch" =~ ^work/[a-z0-9][a-z0-9-]{0,47}$ ]] ;; + group/*) valid_group "${branch#group/}" ;; + slice/group/*/*) + [[ "$branch" =~ ^slice/group/([gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*)/[a-z0-9][a-z0-9-]{0,47}$ ]] && + valid_group "${BASH_REMATCH[1]}" + ;; + slice/*/*) + [[ "$branch" =~ ^slice/[a-z0-9][a-z0-9-]{0,47}/[a-z0-9][a-z0-9-]{0,47}$ ]] + ;; + slice/* | dev | flow | master | main | DETACHED) return 1 ;; + *) return 0 ;; + esac +} + +expected_rebase_target() { load_branch - case "$branch" in dev | master | main | DETACHED) return 1 ;; *) return 0 ;; esac + case "$branch" in + work/* | group/* | slice/group/*/*) return 1 ;; + slice/*/*) + [[ "$branch" =~ ^slice/([a-z0-9][a-z0-9-]{0,47})/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + printf 'origin/work/%s\n' "${BASH_REMATCH[1]}" + ;; + slice/* | dev | flow | master | main | DETACHED) return 1 ;; + *) printf 'origin/dev\n' ;; + esac +} + +expected_pr_base() { + local group="" + load_branch + case "$branch" in + flow) printf 'dev\n' ;; + work/*) + [[ "$branch" =~ ^work/[a-z0-9][a-z0-9-]{0,47}$ ]] || return 1 + printf 'dev\n' + ;; + group/*) + valid_group "${branch#group/}" || return 1 + printf 'flow\n' + ;; + slice/group/*/*) + [[ "$branch" =~ ^slice/group/([gb][0-9]+[a-z]?-[a-z0-9][a-z0-9-]*)/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + group="${BASH_REMATCH[1]}" + valid_group "$group" || return 1 + printf 'group/%s\n' "$group" + ;; + slice/*/*) + [[ "$branch" =~ ^slice/([a-z0-9][a-z0-9-]{0,47})/[a-z0-9][a-z0-9-]{0,47}$ ]] \ + || return 1 + printf 'work/%s\n' "${BASH_REMATCH[1]}" + ;; + slice/* | dev | master | main | DETACHED) return 1 ;; + *) printf 'dev\n' ;; + esac } validate_push() { - local words=() arg positional=() + local words=() arg positional=() lease=0 read -r -a words <<<"$scan" [ "${words[0]:-}" = git ] && [ "${words[1]:-}" = push ] || return 1 - is_work_branch || return 1 + is_writable_branch || return 1 for arg in "${words[@]:2}"; do case "$arg" in -u | --set-upstream | --porcelain | --progress | --no-progress | --no-verify) ;; - --force-with-lease) ;; + --force-with-lease) lease=1 ;; --force-with-lease=* | --force | -f | --mirror | --delete | -d) return 1 ;; -*) return 1 ;; *) positional+=("$arg") ;; @@ -235,11 +294,14 @@ validate_push() { [ "${#positional[@]}" -eq 2 ] || return 1 [ "${positional[0]}" = origin ] || return 1 [ "${positional[1]}" = HEAD ] || [ "${positional[1]}" = "$branch" ] || return 1 + case "$branch" in + work/* | group/* | slice/group/*/*) [ "$lease" -eq 0 ] || return 1 ;; + esac return 0 } validate_gh() { - local words=() sub arg i base="" head="" + local words=() sub arg i base="" head="" required_base="" read -r -a words <<<"$scan" [ "${words[0]:-}" = gh ] || return 1 if [ "${words[1]:-}" = run ]; then @@ -259,7 +321,8 @@ validate_gh() { create) ;; *) return 1 ;; esac - is_work_branch || return 1 + load_branch + required_base="$(expected_pr_base)" || return 1 i=3 while [ "$i" -lt "${#words[@]}" ]; do arg="${words[$i]}" @@ -271,10 +334,19 @@ validate_gh() { esac i=$((i + 1)) done - [ "$base" = dev ] || return 1 + [ "$base" = "$required_base" ] || return 1 [ -z "$head" ] || [ "$head" = "$branch" ] || return 1 } +validate_remote_rebase() { + local words=() expected="" + read -r -a words <<<"$scan" + [ "${#words[@]}" -eq 3 ] || return 1 + [ "${words[0]}" = git ] && [ "${words[1]}" = rebase ] || return 1 + expected="$(expected_rebase_target)" || return 1 + [ "${words[2]}" = "$expected" ] +} + # is_nonexecuting_search_data <command>: true only for a single rg invocation whose # arguments cannot start another command. rg's --pre option is excluded because it executes an # external preprocessor. This lets a literal search pattern name a CLI without treating the data @@ -322,25 +394,42 @@ unsafe_protected_write_redirect() { } git_push_re='(^|[^[:alnum:]_])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[^[:alnum:]_]+push([^[:alnum:]_]|$)' +git_alt_context_mutation_re='(^|[^[:alnum:]_])git[[:space:]]+(-C([[:space:]]+[^[:space:]]+|[^[:space:]]+)|--(git-dir|work-tree)(=|[[:space:]]+)[^[:space:]]+).*[[:space:]](commit|merge|rebase|push)([^[:alnum:]_]|$)' git_remote_rebase_re='(^|[^[:alnum:]_])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[^[:alnum:]_]+rebase([^[:alnum:]_]|$).*((origin|upstream)/|refs/remotes/)' -origin_dev_re='^[[:space:]]*git[[:space:]]+rebase[[:space:]]+origin/dev[[:space:]]*$' +git_integration_re='(^|[^[:alnum:]_])git([[:space:]]+[^[:space:]]+)*[[:space:]]+(merge|rebase)([^[:alnum:]_]|$)' +git_slice_merge_re="(^|[^[:alnum:]_])git[[:space:]]+merge[[:space:]]+([^[:space:]]+[[:space:]]+)*[\"']?slice/" gh_command_re='(^|[^[:alnum:]_./-])gh([^[:alnum:]_./-]|$)|(^|[;&|][[:space:]]*)/[^[:space:]]*/gh([[:space:]]|$)' credential_path_re="(^|[[:space:]\"'=])(~?/)?(\\.gitconfig([^[:alnum:]]|$)|\\.config/(git|gh)/|\\.ssh/|\\.netrc([^[:alnum:]]|$))" token_env_re='(^|[^[:alnum:]_])(GH_TOKEN|GITHUB_TOKEN|GIT_ASKPASS|SSH_AUTH_SOCK)([^[:alnum:]_]|$)' bulk_env_re='^[[:space:]]*(printenv|env|set)[[:space:]]*$' mutating_rail_re='(^|[[:space:]])(tee|rm|mv|cp|ln|truncate|install)[[:space:]]|sed[[:space:]]+-i' +if matches_line "$scan" "$git_alt_context_mutation_re"; then + block "Git mutations through an alternate worktree or Git directory are forbidden" "Autonomy boundary" +fi if matches_line "$scan" "$git_push_re"; then validate_push || block "push is limited to the current non-protected branch on origin; plain force, deletion, mirrors, and protected targets are forbidden" "Autonomy boundary" fi if matches_line "$scan" "$git_remote_rebase_re"; then - if ! { is_work_branch && matches_line "$scan" "$origin_dev_re"; }; then - block "remote rebase is limited to rebasing the current work branch on origin/dev" "Autonomy boundary" - fi + validate_remote_rebase \ + || block "remote rebase must use the exact base derived from the current branch" "Autonomy boundary" fi +if matches_line "$scan" "$git_integration_re"; then + load_branch + case "$branch" in + dev | flow | master | main | DETACHED) + block "merge and rebase on a protected branch or detached HEAD are forbidden" "Autonomy boundary" + ;; + work/* | group/* | slice/group/*/*) + block "integration branch history is helper-managed; use scripts/dev/workstream sync or merge" "Autonomy boundary" + ;; + esac +fi +matches_line "$scan" "$git_slice_merge_re" \ + && block "slice integration is permitted only through the verified workstream helper" "Autonomy boundary" if matches_line "$shell_scan" "$gh_command_re" \ && ! is_nonexecuting_search_data "$scan"; then - validate_gh || block "GitHub access is limited to direct read-only PR/Actions commands and creating the current work branch PR with explicit base dev" "Autonomy boundary" + validate_gh || block "GitHub access is limited to direct read-only PR/Actions commands and the exact PR route derived from the current branch" "Autonomy boundary" fi match_any "$scan" "$pol/deny.patterns" \ @@ -383,14 +472,13 @@ if [ "$maint" != 1 ]; then fi fi -# 5) branch backstop: never commit on dev/master/main (covers a skipped SessionStart bootstrap) +# 5) branch backstop: never commit on protected branches (covers a skipped SessionStart bootstrap) git_commit_re='(^|[^[:alnum:]_])git([[:space:]]+[^[:space:]]+)*[[:space:]]+commit([^[:alnum:]_]|$)' if matches_line "$scan" "$git_commit_re"; then load_branch br="$branch" - case "$br" in - dev | master | main | DETACHED) block "refusing to commit on protected or detached branch '$br' — create a work branch from dev first (SessionStart normally does this)" "Prime directives" ;; - esac + is_writable_branch \ + || block "refusing to commit on protected, detached, or invalid reserved branch '$br'" "Prime directives" fi exit 0 diff --git a/tools/render-adapters.sh b/tools/render-adapters.sh index 91d3126..8fb5d01 100755 --- a/tools/render-adapters.sh +++ b/tools/render-adapters.sh @@ -3,8 +3,8 @@ # three adapters are never hand-maintained. Single policy source: # - policy/allow.commands -> the auto-approve allow set (translated per tool) # - NATIVE_DENY below -> unconditional native belt-and-suspenders rules. Scoped push, -# origin/dev rebase, and PR-create decisions remain in the authoritative -# guard because native prefix rules cannot express branch-aware policy. +# branch-derived rebase, and PR-create decisions remain in the +# authoritative guard because native rules cannot express branch policy. # # Generated files (do not hand-edit the marked/whole regions): # .claude/settings.json (whole file — strict JSON, no comments) diff --git a/tools/session-bootstrap.sh b/tools/session-bootstrap.sh index ff712c6..77d5d2c 100755 --- a/tools/session-bootstrap.sh +++ b/tools/session-bootstrap.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# agent-lab SessionStart hook: never work on dev/master/main. Idempotent; never blocks the session. +# agent-lab SessionStart hook: never write protected branches. Idempotent; never blocks the session. # Usage (from each tool's SessionStart hook): tools/session-bootstrap.sh <tool> # claude|codex|grok -# If HEAD is dev/master/main/detached, create agent/<tool>/<slug> from origin/dev (or local dev); -# otherwise no-op. +# If HEAD is dev/master/main/detached, create agent/<tool>/<slug> from origin/dev (or local dev). +# A flow checkout remains read-only so the final PR can be inspected or opened deliberately. # slug = ${AGENT_LAB_TASK_SLUG:-<UTC timestamp>}, sanitized to a valid ref component. set -uo pipefail @@ -12,6 +12,9 @@ cd "$root" || exit 0 branch="$(git symbolic-ref --short -q HEAD 2>/dev/null || echo DETACHED)" case "$branch" in + flow) + echo "agent-lab: flow is protected and remains read-only; use a group/slice branch for changes" >&2 + ;; dev | master | main | DETACHED) slug="${AGENT_LAB_TASK_SLUG:-$(date -u +%Y%m%d-%H%M%S)}" slug="$(printf '%s' "$slug" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[-.]+//; s/[-.]+$//')" From 2fb40cc99b63c51b3923841d76a95b144e2a8291 Mon Sep 17 00:00:00 2001 From: xormania <127287135+xormania@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:19:46 -0400 Subject: [PATCH 2/2] Disambiguate CodeQL workflow checks --- docs/ci.md | 5 +++-- docs/workstreams.md | 7 +++++-- scripts/dev/workstream | 6 +++++- tests/dev/workstream-cases.sh | 8 ++++---- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/ci.md b/docs/ci.md index 0754208..7f59ea1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -30,8 +30,9 @@ failures block with `1`; missing, extra, stale, skipped, cancelled, malformed, or infrastructure-uncertain evidence fails closed with `125`. CodeQL remains a separate check because GitHub does not expose cross-workflow -jobs through `needs`. Its job name is fixed as `CodeQL` so the merge helper and -hosted rules can require the same unambiguous result. +jobs through `needs`. Its workflow and job names are fixed as `CodeQL` so the merge helper and +hosted rules can require the same unambiguous Actions result. GitHub also emits a same-named +code-scanning result; that result must succeed but cannot substitute for the workflow job. The Docker worker always runs the full runtime gate. Its cache-aware devbox build is a separate timed step, and the gate records runtime-suite timings so diff --git a/docs/workstreams.md b/docs/workstreams.md index 0e18b7a..d2a7475 100644 --- a/docs/workstreams.md +++ b/docs/workstreams.md @@ -127,8 +127,11 @@ its matching workstream. Replay invalidated evidence after any sync. `workstream merge` rereads hosted PR state immediately before acting. It accepts only a same-repository PR whose base is the current checkout and whose head is the exact matching slice or group route. The PR must be open, non-draft, cleanly mergeable, approved, contain its observed current base, report -exactly one successful `Required gates` and `CodeQL` check, and have every reported check completed -successfully. Group integration also requires those checks green on the observed `flow` base. +exactly one successful `CI` workflow job named `Required gates` and one successful `CodeQL` workflow +job named `CodeQL`, and have every reported check completed successfully. GitHub's separate +code-scanning result may also be named `CodeQL`; it neither substitutes for nor conflicts with the +workflow job. Group integration also requires the GitHub Actions jobs green on the observed `flow` +base. Every GitHub read and write is pinned to `github.com/uscient/agent-lab`; ambient repository or host environment variables cannot redirect the helper. The command requests a merge commit pinned to the observed head SHA and confirms GitHub reports the diff --git a/scripts/dev/workstream b/scripts/dev/workstream index 20b416d..f98cadb 100755 --- a/scripts/dev/workstream +++ b/scripts/dev/workstream @@ -244,11 +244,13 @@ case "$command_name" in (.mergeStateStatus == "CLEAN") and (.reviewDecision == "APPROVED") and ((.statusCheckRollup | length) > 0) and - ((.statusCheckRollup | map(.name) | unique | length) == + ((.statusCheckRollup | map([(.workflowName // ""), .name]) | unique | length) == (.statusCheckRollup | length)) and (([.statusCheckRollup[] | select(.name == "Required gates" and + .workflowName == "CI" and .status == "COMPLETED" and .conclusion == "SUCCESS")] | length) == 1) and (([.statusCheckRollup[] | select(.name == "CodeQL" and + .workflowName == "CodeQL" and .status == "COMPLETED" and .conclusion == "SUCCESS")] | length) == 1) and (all(.statusCheckRollup[]; .status == "COMPLETED" and .conclusion == "SUCCESS")) ' >/dev/null; then @@ -269,8 +271,10 @@ case "$command_name" in if ! printf '%s' "$base_checks" | "$real_jq" -e ' (.check_runs | type == "array") and (([.check_runs[] | select(.name == "Required gates" and + .app.slug == "github-actions" and .status == "completed" and .conclusion == "success")] | length) == 1) and (([.check_runs[] | select(.name == "CodeQL" and + .app.slug == "github-actions" and .status == "completed" and .conclusion == "success")] | length) == 1) ' >/dev/null; then refuse "observed flow base $base_sha is not green for Required gates and CodeQL" diff --git a/tests/dev/workstream-cases.sh b/tests/dev/workstream-cases.sh index 7ca6ec1..251f629 100755 --- a/tests/dev/workstream-cases.sh +++ b/tests/dev/workstream-cases.sh @@ -50,8 +50,8 @@ chmod +x "$fake_bin/gh" base_oid='1111111111111111111111111111111111111111' head_oid='0123456789abcdef0123456789abcdef01234567' -valid_json='{"number":17,"state":"OPEN","isDraft":false,"baseRefName":"work/demo","baseRefOid":"1111111111111111111111111111111111111111","headRefName":"slice/demo/format","headRefOid":"0123456789abcdef0123456789abcdef01234567","isCrossRepository":false,"headRepository":{"nameWithOwner":"uscient/agent-lab"},"mergeStateStatus":"CLEAN","reviewDecision":"APPROVED","statusCheckRollup":[{"name":"Fast","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"Required gates","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"CodeQL","status":"COMPLETED","conclusion":"SUCCESS"}]}' -valid_base_checks='{"check_runs":[{"name":"Required gates","status":"completed","conclusion":"success"},{"name":"CodeQL","status":"completed","conclusion":"success"}]}' +valid_json='{"number":17,"state":"OPEN","isDraft":false,"baseRefName":"work/demo","baseRefOid":"1111111111111111111111111111111111111111","headRefName":"slice/demo/format","headRefOid":"0123456789abcdef0123456789abcdef01234567","isCrossRepository":false,"headRepository":{"nameWithOwner":"uscient/agent-lab"},"mergeStateStatus":"CLEAN","reviewDecision":"APPROVED","statusCheckRollup":[{"name":"Fast","workflowName":"CI","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"Required gates","workflowName":"CI","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"CodeQL","workflowName":"CodeQL","status":"COMPLETED","conclusion":"SUCCESS"},{"name":"CodeQL","workflowName":"","status":"COMPLETED","conclusion":"SUCCESS"}]}' +valid_base_checks='{"check_runs":[{"name":"Required gates","app":{"slug":"github-actions"},"status":"completed","conclusion":"success"},{"name":"CodeQL","app":{"slug":"github-actions"},"status":"completed","conclusion":"success"},{"name":"CodeQL","app":{"slug":"github-code-scanning"},"status":"completed","conclusion":"success"}]}' route_fixture="$work/route" mkdir -p "$route_fixture/scripts/dev" @@ -265,7 +265,7 @@ else fail "approved current-base group PR integrates into flow through the helper" fi -missing_base_codeql='{"check_runs":[{"name":"Required gates","status":"completed","conclusion":"success"}]}' +missing_base_codeql='{"check_runs":[{"name":"Required gates","app":{"slug":"github-actions"},"status":"completed","conclusion":"success"},{"name":"CodeQL","app":{"slug":"github-code-scanning"},"status":"completed","conclusion":"success"}]}' if run_merge "$flow_json" "$route_fixture/scripts/dev/workstream" ahead "$missing_base_codeql"; then fail "group integration waits for a green CodeQL result on the flow base" elif ! grep -q '^pr merge ' "$gh_log"; then @@ -310,7 +310,7 @@ else fi missing_codeql="$(printf '%s' "$valid_json" | jq -c \ - '.statusCheckRollup |= map(select(.name != "CodeQL"))')" + '.statusCheckRollup |= map(select(.name != "CodeQL" or .workflowName != "CodeQL"))')" if run_merge "$missing_codeql"; then fail "missing CodeQL PR result blocks integration" elif ! grep -q '^pr merge ' "$gh_log"; then