From c4a66c9f15c9f103135f19e29ef2de917fd3e9a0 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 16 Sep 2026 16:02:08 +0000 Subject: [PATCH 1/2] fix(acq): use sandbox workspace for github scoping --- acq | 25 ++++++-- acq.backends/common.sh | 57 ++++++++++++++++--- acq.backends/msb.sh | 7 +++ acq.backends/sbx.sh | 5 ++ ...13-per-sandbox-github-token-downscoping.md | 6 +- docs/howto/acq.md | 8 ++- docs/howto/sbx.md | 7 ++- scripts/test-acq-lib.sh | 3 + test/bats/125-github-scope.bats | 30 ++++++++++ test/bats/30-dispatch-routing.bats | 18 ++++++ test/bats/90-sbx-startup-kit.bats | 7 ++- 11 files changed, 152 insertions(+), 21 deletions(-) diff --git a/acq b/acq index 9e392a84..9d063b0a 100755 --- a/acq +++ b/acq @@ -102,8 +102,9 @@ Subcommands: secret ls [-g|SANDBOX] List acq-managed secrets (no values). secret import [SERVICE] [-g|SANDBOX] Import known service tokens from the host environment into the acq store. - github-scope SANDBOX [PATH] Scope a GitHub token to the - workspace's repos (least-privilege PAT). + github-scope SANDBOX [PATH] Scope a GitHub token to the workspace's + repos; PATH defaults to the recorded + sandbox workspace when available. usai-rotate-api-key Rotate the USAi API key. version Show acq version + backend info. doctor Backend health check + write config. @@ -364,7 +365,8 @@ Usage: acq github-scope SANDBOX [WORKSPACE_PATH] Guides minting a fine-grained PAT scoped to the workspace's repos and stores it -sandbox-scoped (ADR-0013). WORKSPACE_PATH defaults to the current directory. +sandbox-scoped (ADR-0013). When omitted, WORKSPACE_PATH defaults to the recorded +workspace for SANDBOX. Fine-grained PAT setup supports one GitHub owner per run. U ;; usai-rotate-api-key) @@ -1404,7 +1406,22 @@ case "$subcommand" in acq_backend_prepare gs_sandbox="${1:-}" [ -n "$gs_sandbox" ] || { echo "acq: github-scope: missing sandbox name" >&2; echo " usage: acq github-scope SANDBOX [WORKSPACE_PATH]" >&2; exit 1; } - gs_ws="${2:-$PWD}" + if [ -n "${2:-}" ]; then + gs_ws="$2" + else + gs_ws=$(acq_workspace_record_read "${ACQ_RESOLVED_BACKEND}" "$gs_sandbox") + if [ -z "$gs_ws" ] && command -v acq_backend_workspace_for >/dev/null 2>&1; then + gs_ws=$(acq_backend_workspace_for "$gs_sandbox") + [ -z "$gs_ws" ] || acq_workspace_record_write "${ACQ_RESOLVED_BACKEND}" "$gs_sandbox" "$gs_ws" || true + fi + if [ -z "$gs_ws" ]; then + echo "acq: github-scope: no workspace path was provided, and no workspace" >&2 + echo " is recorded for sandbox '$gs_sandbox'. Re-run with:" >&2 + echo " acq github-scope $gs_sandbox /path/to/workspace" >&2 + exit 1 + fi + echo "acq: github-scope: using recorded workspace for '$gs_sandbox': $gs_ws" >&2 + fi github_scope_sandbox "$gs_sandbox" "$gs_ws" ;; diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 811c09f3..bbfc07d1 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -1275,8 +1275,9 @@ _acq_provenance_file() { acq_provenance_write() { local backend="${1:-}" name="${2:-}" [ -n "$backend" ] && [ -n "$name" ] || return 1 - local file dir ts + local file dir ts workspace file=$(_acq_provenance_file "$backend" "$name") || return 1 + workspace=$(acq_provenance_field "$backend" "$name" workspace) dir=$(dirname "$file") if ! mkdir -p "$dir" 2>/dev/null; then acq_debug "provenance: could not create state dir: $dir" @@ -1293,6 +1294,7 @@ acq_provenance_write() { printf 'applied_ref=%s\n' "$PATTERNS_KIT_REF" printf 'backend=%s\n' "$backend" printf 'applied_at=%s\n' "$ts" + [ -z "$workspace" ] || printf 'workspace=%s\n' "$workspace" } > "$tmp" 2>/dev/null || { acq_debug "provenance: write failed: $tmp"; rm -f "$tmp" 2>/dev/null; return 1; } mv -f "$tmp" "$file" 2>/dev/null || { acq_debug "provenance: mv failed: $file"; rm -f "$tmp" 2>/dev/null; return 1; } acq_debug "provenance: recorded $backend/$name applied_ref=$PATTERNS_KIT_REF" @@ -1314,6 +1316,27 @@ acq_provenance_field() { ' "$file" 2>/dev/null || true } +# Remember the primary host workspace mounted into a sandbox. This is non-secret +# host-side state used by commands that take only SANDBOX later (for example, +# `acq github-scope SANDBOX`) so they do not accidentally inspect the caller's +# current directory instead of the sandbox's workspace. +acq_workspace_record_write() { + local backend="${1:-}" name="${2:-}" workspace="${3:-}" + [ -n "$backend" ] && [ -n "$name" ] && [ -n "$workspace" ] || return 0 + local file dir tmp + file=$(_acq_provenance_file "$backend" "$name") || return 1 + dir=$(dirname "$file") + mkdir -p "$dir" 2>/dev/null || return 1 + tmp="${file}.workspace.$$" + awk -F= '$1 != "workspace" { print }' "$file" 2>/dev/null > "$tmp" || : > "$tmp" + printf 'workspace=%s\n' "$workspace" >> "$tmp" || { rm -f "$tmp" 2>/dev/null; return 1; } + mv -f "$tmp" "$file" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; } +} + +acq_workspace_record_read() { + acq_provenance_field "${1:-}" "${2:-}" workspace +} + # Classify a sandbox's currency against the LOCAL pinned PATTERNS_KIT_REF. # Echoes exactly one of: current | stale | unknown. # current — record exists and applied_ref == local PATTERNS_KIT_REF. @@ -2075,11 +2098,13 @@ _acq_github_pat_url() { # github_scope_sandbox SANDBOX WORKSPACE — guide the user through minting a # fine-grained PAT scoped to the workspace's repos and store it sandbox-scoped. -# Warn-not-block: returns 0 even if the user declines. Never places the token in -# argv (delegates to acq_secret_set_interactive via the backend secret path). +# Refuses multi-owner workspaces because acq stores one sandbox-scoped GitHub +# token today, while GitHub fine-grained PATs are single-owner. Never places the +# token in argv (delegates to acq_secret_set_interactive via the backend secret +# path). github_scope_sandbox() { local sandbox="$1" ws="${2:-}" - local repos owners="" nwo owner + local repos owners="" nwo owner owner_count=0 repos=$(detect_workspace_repos "$ws") if [ -z "$repos" ]; then @@ -2091,16 +2116,32 @@ github_scope_sandbox() { while IFS= read -r nwo; do [ -n "$nwo" ] || continue owner="${nwo%%/*}" - case "$owners" in *"|$owner|"*) ;; *) owners="$owners|$owner|" ;; esac + case "$owners" in *"|$owner|"*) ;; *) owners="$owners|$owner|"; owner_count=$((owner_count + 1)) ;; esac done <&2 + echo " This workspace contains GitHub repositories owned by multiple accounts," >&2 + echo " but acq stores one sandbox-scoped GitHub token today and GitHub" >&2 + echo " fine-grained PATs are scoped to a single owner." >&2 + echo "" >&2 + echo " Detected repositories:" >&2 + while IFS= read -r nwo; do [ -n "$nwo" ] && printf ' %s\n' "$nwo" >&2; done <&2 + echo " Re-run with a workspace path containing repos from only one owner:" >&2 + echo " acq github-scope $sandbox /path/to/one-owner-workspace" >&2 + return 1 + fi + echo "acq: scoping a GitHub token for sandbox '$sandbox'." >&2 echo "" >&2 echo " GitHub has no API to mint a fine-grained PAT, so create it in the" >&2 - echo " browser. For EACH owner below, open the pre-filled link, select" >&2 - echo " 'Only select repositories' and choose the repo(s) listed below," >&2 + echo " browser. Open the pre-filled link, select 'Only select" >&2 + echo " repositories' and choose the repo(s) listed below," >&2 echo " then generate the token and paste it back here." >&2 echo " Default permissions: Contents=Read/Write, Pull requests=Read/Write," >&2 echo " Issues=Read/Write, Actions=Read (lets the agent read the PR-check" >&2 @@ -2191,7 +2232,7 @@ advise_github_scope() { printf 'acq: scope a GitHub token for this sandbox now? [y/N] ' >&2 read -r ans 2>/dev/null || ans="" case "$ans" in - y|Y|yes|YES) github_scope_sandbox "$sandbox" "$ws" ;; + y|Y|yes|YES) github_scope_sandbox "$sandbox" "$ws" || true ;; *) echo "acq: continuing without scoping (run 'acq github-scope $sandbox $ws' anytime)." >&2 ;; esac return 0 diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index b092aa61..36aac253 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -3198,6 +3198,7 @@ EOF # Best-effort: a provenance write failure never affects the # sandbox. Reached only when provision did not abort earlier under set -e. acq_provenance_write msb "$name" || true + acq_workspace_record_write msb "$name" "$ACQ_MSB_GUEST_WORKSPACE" || true # Persist the CLI (`--kit`) and extra (ACQ_EXTRA_KITS) kit refs so a later # `acq start`/`acq restart` can reload them and re-run their startup services @@ -4233,6 +4234,12 @@ _acq_msb_workspace_for() { printf '%s\n' "$ws" } +acq_backend_workspace_for() { + local ws + ws=$(_acq_msb_workspace_for "$1") + [ "$ws" = "/home/agent" ] || printf '%s\n' "$ws" +} + # _acq_msb_term_flags_into ARRVAR — `-e` flags forwarding the host's terminal # identity (TERM, COLORTERM) into an interactive (-t) session. msb does not # propagate the caller's terminal env into the guest PTY, so sessions see diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 24611b73..a93241d4 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -494,6 +494,7 @@ acq_backend_provision() { # create must not leave a record claiming the sandbox is current. if [ "$_rc" -eq 0 ]; then acq_provenance_write sbx "$name" || true + acq_workspace_record_write sbx "$name" "$_primary_ws" || true _acq_sbx_seed_extra_kit_marker "$name" # Persist the CLI (`--kit`) / extra kit refs alongside provenance so a later # resume heal can reload them (see acq_cli_kits_write). Best-effort. @@ -568,6 +569,10 @@ acq_backend_run() { sbx exec "$name" "$@" } +acq_backend_workspace_for() { + sbx exec "$1" -- sh -c 'printf "%s" "${ACQ_WORKSPACE:-}"' /dev/null || true +} + # --------------------------------------------------------------------------- # acq_backend_shell — interactive human shell # --------------------------------------------------------------------------- diff --git a/docs/adr/0013-per-sandbox-github-token-downscoping.md b/docs/adr/0013-per-sandbox-github-token-downscoping.md index 83bb3c77..e46f9b7c 100644 --- a/docs/adr/0013-per-sandbox-github-token-downscoping.md +++ b/docs/adr/0013-per-sandbox-github-token-downscoping.md @@ -91,8 +91,10 @@ Concretely: working for back-compat), and the per-sandbox scoped flow becomes the documented default. -Multiple distinct owners in one workspace are handled by guiding one token per -owner (fine-grained PATs are single-owner by design). +Multiple distinct owners in one workspace fail closed with a clear explanation. +Fine-grained PATs are single-owner by design, and `acq` stores one sandbox-scoped +GitHub token today; asking for several owner tokens would imply support the +secret store and backend binding do not provide. ## Considered Alternatives (rejected) diff --git a/docs/howto/acq.md b/docs/howto/acq.md index 77f74f5e..7c8e3f77 100644 --- a/docs/howto/acq.md +++ b/docs/howto/acq.md @@ -146,9 +146,11 @@ the guest. > ./acq github-scope /path/to/your/project > ``` > -> This is also what `acq run` offers interactively. Fine-grained tokens can't -> contribute to public repos you're not a member of or call the Checks API — fall -> back to a global token for those cases. See +> This is also what `acq run` offers interactively. If you omit the path later, +> `acq github-scope ` uses the workspace recorded when the sandbox +> was created. Fine-grained tokens can't contribute to public repos you're not a +> member of, call the Checks API, or span multiple owners in one token — fall back +> to a global token for those cases. See > [ADR-0013](../adr/0013-per-sandbox-github-token-downscoping.md). ### Rotate your USAi key diff --git a/docs/howto/sbx.md b/docs/howto/sbx.md index d2e3edb6..1e0ca0a2 100644 --- a/docs/howto/sbx.md +++ b/docs/howto/sbx.md @@ -160,7 +160,8 @@ sbx secret set -g anthropic > injected into *every* sandbox, giving each one access to *all* your > repositories. For least privilege, let `acq` scope a **fine-grained** token to > just the repos in your workspace — on `acq run` it detects the mounted repos -> and guides you, or run it explicitly: +> and guides you, or run it explicitly. If you omit the path, `acq` uses the +> workspace recorded when the sandbox was created: > > ```bash > acq github-scope /path/to/your/project @@ -168,8 +169,8 @@ sbx secret set -g anthropic > > See [ADR-0013](../adr/0013-per-sandbox-github-token-downscoping.md) for the > rationale and the alternatives considered. Fine-grained tokens can't -> contribute to public repos you're not a member of or call the Checks API — -> use the global token below for those cases. +> contribute to public repos you're not a member of, call the Checks API, or span +> multiple owners in one token — use the global token below for those cases. **Deprecated (broad, global) path** — kept for back-compat: diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 570f7be5..727821e2 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -154,6 +154,9 @@ case "${1:-}" in else printf '%s|0' "${STUB_KEY_STATUS:-200}" fi ;; + *'${ACQ_WORKSPACE:-}'*) + [ -n "${STUB_RECORDED_WORKSPACE+x}" ] || exit 1 + printf '%s' "$STUB_RECORDED_WORKSPACE" ;; *"postinstall.mjs"*) # Model a successful postinstall: mark opencode fixed so the follow-up # `opencode --version` probe passes. diff --git a/test/bats/125-github-scope.bats b/test/bats/125-github-scope.bats index ae1c7820..4155d1f3 100644 --- a/test/bats/125-github-scope.bats +++ b/test/bats/125-github-scope.bats @@ -15,9 +15,12 @@ setup() { # A throwaway workspace tree with assorted git remotes. GWT="$STUBDIR/gh" mkdir -p "$GWT/repoGH" "$GWT/wsMulti/a" "$GWT/wsMulti/b" "$GWT/wsGL" "$GWT/empty" + mkdir -p "$GWT/wsOneOwner/a" "$GWT/wsOneOwner/b" ( cd "$GWT/repoGH" && git init -q && git remote add origin https://github.com/GSA-TTS/quickstart.git ) ( cd "$GWT/wsMulti/a" && git init -q && git remote add origin git@github.com:orgOne/repo1.git ) ( cd "$GWT/wsMulti/b" && git init -q && git remote add origin https://github.com/orgTwo/repo2 ) + ( cd "$GWT/wsOneOwner/a" && git init -q && git remote add origin git@github.com:orgOne/repo1.git ) + ( cd "$GWT/wsOneOwner/b" && git init -q && git remote add origin https://github.com/orgOne/repo2 ) ( cd "$GWT/wsGL" && git init -q && git remote add origin https://gitlab.com/x/y.git ) } teardown() { acq_teardown_stubs; } @@ -66,6 +69,33 @@ _common() { # FUNC ARGS... _common detect_workspace_repos "$GWT/empty"; assert_output '' } +@test "gh-scope: refuses workspaces spanning multiple GitHub owners" { + run env ACQ_SECRET_TEST_VALUE=ghp_fake bash -c ' + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + github_scope_sandbox sb1 "'"$GWT"'/wsMulti" 2>&1 + ' + assert_failure + assert_output --partial 'multiple accounts' + assert_output --partial 'orgOne/repo1' + assert_output --partial 'orgTwo/repo2' + refute_output --partial 'Enter GitHub token' +} + +@test "gh-scope: accepts multiple repos from one GitHub owner" { + run env ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ + ACQ_SECRET_TEST_VALUE=ghp_fake bash -c ' + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + github_scope_sandbox sb1 "'"$GWT"'/wsOneOwner" 2>&1 + ' + assert_success + assert_output --partial "Owner 'orgOne'" + assert_output --partial 'orgOne/repo1' + assert_output --partial 'orgOne/repo2' + refute_output --partial 'For EACH owner' +} + # advise_github_scope gate: fires iff repos present AND no sandbox-scoped github # secret (regardless of a global one). Force the file secret store into a unique # temp dir per invocation so planted secrets don't leak between cases. diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 0510e5eb..6a08e37d 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -81,6 +81,24 @@ _seed_usai() { assert_output --partial 'no repo-scoped GitHub token' } +@test "github-scope: uses recorded sandbox workspace when path is omitted" { + local parent="$STUBDIR/parent" pic="$STUBDIR/parent/pic" other="$STUBDIR/parent/other" + mkdir -p "$pic/repo" "$other" + ( cd "$pic/repo" && git init -q && git remote add origin https://github.com/GSA-TTS/pic-site.git ) + ( cd "$other" && git init -q && git remote add origin https://github.com/mogul/artemis.git ) + _seed_usai + run env ACQ_BACKEND=sbx ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ + "$ACQ" create --name opencode-pic opencode "$pic" + assert_success + + run env ACQ_BACKEND=sbx ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ + ACQ_SECRET_TEST_VALUE=ghp_fake bash -c 'cd "$1" && "$2" github-scope opencode-pic' _ "$parent" "$ACQ" + assert_success + assert_output --partial "using recorded workspace for 'opencode-pic': $pic" + assert_output --partial 'GSA-TTS/pic-site' + refute_output --partial 'mogul/artemis' +} + @test "dispatch: create runs a non-blocking USAi key advisory (warns but never aborts)" { local proj="$STUBDIR/keyproj"; mkdir -p "$proj" _seed_usai diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 54e188eb..3d387195 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -164,7 +164,12 @@ esac STUB chmod +x "$STUBDIR/sbx" printf 'probebox\n' > "$STUBDIR/.sandbox_list" - acq_backend_ensure_kits_applied probebox >/dev/null 2>&1 || true + ( + unset EMAIL GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL + unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL + HOME="$STUBDIR/nohome" XDG_CONFIG_HOME="$STUBDIR/noconfig" \ + acq_backend_ensure_kits_applied probebox >/dev/null 2>&1 || true + ) local log; log=$(cat "$CALLS") assert_regex "$log" '\.agentic-coding-playbook/AGENTS\.md' refute_regex "$log" '\.agentic-coding-playbook/\.git' From 48a7aa02064eb3a8c9ce3c35aacec1b111489aef Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 24 Sep 2026 04:55:03 +0000 Subject: [PATCH 2/2] fix(acq): trust only host workspace provenance --- acq | 4 -- acq.backends/common.sh | 44 ++++++++++++++---- acq.backends/msb.sh | 22 +++++---- acq.backends/sbx.sh | 4 -- test/bats/116-clone-option.bats | 9 ++++ test/bats/117-msys-path-forms.bats | 12 +++++ test/bats/125-github-scope.bats | 73 +++++++++++++++++++++++++++++- test/bats/30-dispatch-routing.bats | 26 +++++++++++ 8 files changed, 167 insertions(+), 27 deletions(-) diff --git a/acq b/acq index 9d063b0a..a67673a5 100755 --- a/acq +++ b/acq @@ -1410,10 +1410,6 @@ case "$subcommand" in gs_ws="$2" else gs_ws=$(acq_workspace_record_read "${ACQ_RESOLVED_BACKEND}" "$gs_sandbox") - if [ -z "$gs_ws" ] && command -v acq_backend_workspace_for >/dev/null 2>&1; then - gs_ws=$(acq_backend_workspace_for "$gs_sandbox") - [ -z "$gs_ws" ] || acq_workspace_record_write "${ACQ_RESOLVED_BACKEND}" "$gs_sandbox" "$gs_ws" || true - fi if [ -z "$gs_ws" ]; then echo "acq: github-scope: no workspace path was provided, and no workspace" >&2 echo " is recorded for sandbox '$gs_sandbox'. Re-run with:" >&2 diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 0c99759a..5d6c7d39 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -1303,9 +1303,12 @@ _acq_provenance_file() { acq_provenance_write() { local backend="${1:-}" name="${2:-}" [ -n "$backend" ] && [ -n "$name" ] || return 1 - local file dir ts workspace + local file dir ts workspace workspace_source file=$(_acq_provenance_file "$backend" "$name") || return 1 - workspace=$(acq_provenance_field "$backend" "$name" workspace) + workspace_source=$(acq_provenance_field "$backend" "$name" workspace_source) + if [ "$workspace_source" = "host" ]; then + workspace=$(acq_provenance_field "$backend" "$name" workspace) + fi dir=$(dirname "$file") if ! mkdir -p "$dir" 2>/dev/null; then acq_debug "provenance: could not create state dir: $dir" @@ -1322,7 +1325,10 @@ acq_provenance_write() { printf 'applied_ref=%s\n' "$PATTERNS_KIT_REF" printf 'backend=%s\n' "$backend" printf 'applied_at=%s\n' "$ts" - [ -z "$workspace" ] || printf 'workspace=%s\n' "$workspace" + if [ -n "${workspace:-}" ]; then + printf 'workspace_source=host\n' + printf 'workspace=%s\n' "$workspace" + fi } > "$tmp" 2>/dev/null || { acq_debug "provenance: write failed: $tmp"; rm -f "$tmp" 2>/dev/null; return 1; } mv -f "$tmp" "$file" 2>/dev/null || { acq_debug "provenance: mv failed: $file"; rm -f "$tmp" 2>/dev/null; return 1; } acq_debug "provenance: recorded $backend/$name applied_ref=$PATTERNS_KIT_REF" @@ -1351,18 +1357,38 @@ acq_provenance_field() { acq_workspace_record_write() { local backend="${1:-}" name="${2:-}" workspace="${3:-}" [ -n "$backend" ] && [ -n "$name" ] && [ -n "$workspace" ] || return 0 + case "$workspace" in + *$'\n'*|*$'\r'*) return 1 ;; + esac + local workspace_check + workspace_check="$workspace" + if [ ! -d "$workspace_check" ] && command -v cygpath >/dev/null 2>&1; then + workspace_check=$(cygpath -u "$workspace" 2>/dev/null || printf '%s' "$workspace") + fi + [ -d "$workspace_check" ] || return 1 local file dir tmp file=$(_acq_provenance_file "$backend" "$name") || return 1 dir=$(dirname "$file") mkdir -p "$dir" 2>/dev/null || return 1 tmp="${file}.workspace.$$" - awk -F= '$1 != "workspace" { print }' "$file" 2>/dev/null > "$tmp" || : > "$tmp" - printf 'workspace=%s\n' "$workspace" >> "$tmp" || { rm -f "$tmp" 2>/dev/null; return 1; } + { + if [ -f "$file" ]; then + awk -F= '$1 != "workspace" && $1 != "workspace_source" { print }' "$file" 2>/dev/null || true + else + printf 'schema=1\n' + printf 'backend=%s\n' "$backend" + fi + printf 'workspace_source=host\n' + printf 'workspace=%s\n' "$workspace" + } > "$tmp" || { rm -f "$tmp" 2>/dev/null; return 1; } mv -f "$tmp" "$file" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 1; } } acq_workspace_record_read() { - acq_provenance_field "${1:-}" "${2:-}" workspace + local backend="${1:-}" name="${2:-}" source + source=$(acq_provenance_field "$backend" "$name" workspace_source) + [ "$source" = "host" ] || return 0 + acq_provenance_field "$backend" "$name" workspace } # Classify a sandbox's currency against the LOCAL pinned PATTERNS_KIT_REF. @@ -2132,7 +2158,7 @@ _acq_github_pat_url() { # path). github_scope_sandbox() { local sandbox="$1" ws="${2:-}" - local repos owners="" nwo owner owner_count=0 + local repos owners="" nwo owner owner_key owner_count=0 repos=$(detect_workspace_repos "$ws") if [ -z "$repos" ]; then @@ -2144,7 +2170,8 @@ github_scope_sandbox() { while IFS= read -r nwo; do [ -n "$nwo" ] || continue owner="${nwo%%/*}" - case "$owners" in *"|$owner|"*) ;; *) owners="$owners|$owner|"; owner_count=$((owner_count + 1)) ;; esac + owner_key=$(printf '%s' "$owner" | tr '[:upper:]' '[:lower:]') + case "$owners" in *"|$owner_key="*) ;; *) owners="$owners|$owner_key=$owner|"; owner_count=$((owner_count + 1)) ;; esac done <&2 echo " Owner '$o' — open:" >&2 printf ' %s\n' "$(_acq_github_pat_url "$o" "$sandbox")" >&2 diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 60dc01be..c5253ae5 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -2958,7 +2958,7 @@ EOF fi ACQ_MSB_GUEST_WORKSPACE="" - local _wi _wspec _wpath _wro _wsrc _whost _first_guest="" + local _wi _wspec _wpath _wro _wsrc _whost _first_record _first_guest="" _first_host="" for _wi in ${_ws_recs[@]+"${!_ws_recs[@]}"}; do _wspec="${_ws_recs[$_wi]}" # Split an optional trailing ":ro" (read-only) marker from the path. @@ -2991,7 +2991,16 @@ EOF # drive form under MSYS), the guest side is the POSIX path the Linux microVM # uses (canonicalize_path). A single value for both is the bug this fixes. _wsrc="$_wpath" - [ -n "$_clone_src" ] && [ -z "$_first_guest" ] && _wsrc="$_clone_src" + if command -v host_path >/dev/null 2>&1; then + _first_record=$(host_path "$_wpath") + else + _first_record="$_wpath" + fi + if [ -z "$_first_guest" ]; then + _first_guest="$_wpath" + _first_host="$_first_record" + fi + [ -n "$_clone_src" ] && [ "$_wi" -eq 0 ] && _wsrc="$_clone_src" if command -v host_path >/dev/null 2>&1; then _whost=$(host_path "$_wsrc") else @@ -2999,7 +3008,6 @@ EOF fi create_flags+=(--volume "${_whost}:${_wpath}${_wro}") acq_debug "msb volume: ${_whost} (host) -> ${_wpath}${_wro} (guest)" - [ -z "$_first_guest" ] && _first_guest="$_wpath" done # Decide the agent's starting directory (recorded for attach). Explicit @@ -3258,7 +3266,7 @@ EOF # Best-effort: a provenance write failure never affects the # sandbox. Reached only when provision did not abort earlier under set -e. acq_provenance_write msb "$name" || true - acq_workspace_record_write msb "$name" "$ACQ_MSB_GUEST_WORKSPACE" || true + acq_workspace_record_write msb "$name" "$_first_host" || true # Persist the CLI (`--kit`) and extra (ACQ_EXTRA_KITS) kit refs so a later # `acq start`/`acq restart` can reload them and re-run their startup services @@ -4307,12 +4315,6 @@ _acq_msb_workspace_for() { printf '%s\n' "$ws" } -acq_backend_workspace_for() { - local ws - ws=$(_acq_msb_workspace_for "$1") - [ "$ws" = "/home/agent" ] || printf '%s\n' "$ws" -} - # _acq_msb_term_flags_into ARRVAR — `-e` flags forwarding the host's terminal # identity (TERM, COLORTERM) into an interactive (-t) session. msb does not # propagate the caller's terminal env into the guest PTY, so sessions see diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index a93241d4..cc402702 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -569,10 +569,6 @@ acq_backend_run() { sbx exec "$name" "$@" } -acq_backend_workspace_for() { - sbx exec "$1" -- sh -c 'printf "%s" "${ACQ_WORKSPACE:-}"' /dev/null || true -} - # --------------------------------------------------------------------------- # acq_backend_shell — interactive human shell # --------------------------------------------------------------------------- diff --git a/test/bats/116-clone-option.bats b/test/bats/116-clone-option.bats index 46973f58..f54aa4e0 100644 --- a/test/bats/116-clone-option.bats +++ b/test/bats/116-clone-option.bats @@ -81,6 +81,15 @@ _create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } assert_regex "$(_create_line msb)" "--volume [^ ]*/clones/shell-cloneproj/cloneproj:${repo}( |\$)" } +@test "clone(msb): records original host checkout, not scratch, for github-scope" { + _msb_clone -- create shell --clone "$CLONEPROJ" + local repo; load_acq; repo=$(canonicalize_path "$CLONEPROJ") + run acq_workspace_record_read msb shell-cloneproj + assert_success + assert_output "$repo" + refute_output --partial '/clones/' +} + @test "clone(msb): a non-git workspace fails the create before the backend runs" { local plain="$STUBDIR/plainproj"; mkdir -p "$plain" _msb_clone -- create shell --clone "$plain" diff --git a/test/bats/117-msys-path-forms.bats b/test/bats/117-msys-path-forms.bats index 49532fe9..973603e4 100644 --- a/test/bats/117-msys-path-forms.bats +++ b/test/bats/117-msys-path-forms.bats @@ -33,6 +33,7 @@ case "$mode" in -u) for p in "$@"; do case "$p" in + C:/tmp/*) printf '/tmp/%s\n' "${p#C:/tmp/}" ;; [A-Za-z]:/*) d=$(printf '%s' "${p%%:*}" | tr 'A-Z' 'a-z'); printf '/%s%s\n' "$d" "${p#*:}" ;; *) printf '%s\n' "$p" ;; esac @@ -103,6 +104,17 @@ _msys_provision() { # NAME WS refute_regex "$line" "ACQ_WORKSPACE=${host}( |\$)" } +@test "msys: recorded github-scope workspace uses the host form" { + _plant_cygpath_stub + mkdir -p "$STUBDIR/msysws" + _msys_provision shell-msysws "$STUBDIR/msysws" + load_acq + run acq_workspace_record_read msb shell-msysws + assert_success + assert_output "$(host_path "$STUBDIR/msysws")" + refute_output "$(canonicalize_path "$STUBDIR/msysws")" +} + @test "msys: an ACQ_MSB_WORKSPACE override is canonicalized to the guest form" { _plant_cygpath_stub run bash -c ' diff --git a/test/bats/125-github-scope.bats b/test/bats/125-github-scope.bats index 4155d1f3..583554a9 100644 --- a/test/bats/125-github-scope.bats +++ b/test/bats/125-github-scope.bats @@ -15,12 +15,14 @@ setup() { # A throwaway workspace tree with assorted git remotes. GWT="$STUBDIR/gh" mkdir -p "$GWT/repoGH" "$GWT/wsMulti/a" "$GWT/wsMulti/b" "$GWT/wsGL" "$GWT/empty" - mkdir -p "$GWT/wsOneOwner/a" "$GWT/wsOneOwner/b" + mkdir -p "$GWT/wsOneOwner/a" "$GWT/wsOneOwner/b" "$GWT/wsCaseOwner/a" "$GWT/wsCaseOwner/b" ( cd "$GWT/repoGH" && git init -q && git remote add origin https://github.com/GSA-TTS/quickstart.git ) ( cd "$GWT/wsMulti/a" && git init -q && git remote add origin git@github.com:orgOne/repo1.git ) ( cd "$GWT/wsMulti/b" && git init -q && git remote add origin https://github.com/orgTwo/repo2 ) ( cd "$GWT/wsOneOwner/a" && git init -q && git remote add origin git@github.com:orgOne/repo1.git ) ( cd "$GWT/wsOneOwner/b" && git init -q && git remote add origin https://github.com/orgOne/repo2 ) + ( cd "$GWT/wsCaseOwner/a" && git init -q && git remote add origin git@github.com:OrgOne/repo1.git ) + ( cd "$GWT/wsCaseOwner/b" && git init -q && git remote add origin https://github.com/orgone/repo2 ) ( cd "$GWT/wsGL" && git init -q && git remote add origin https://gitlab.com/x/y.git ) } teardown() { acq_teardown_stubs; } @@ -96,6 +98,75 @@ _common() { # FUNC ARGS... refute_output --partial 'For EACH owner' } +@test "gh-scope: treats owner case variants as the same GitHub owner" { + run env ACQ_SECRET_FORCE_FILE=1 ACQ_SECRET_FILE_DIR="$STUBDIR/secrets" \ + ACQ_SECRET_TEST_VALUE=ghp_fake bash -c ' + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + github_scope_sandbox sb1 "'"$GWT"'/wsCaseOwner" 2>&1 + ' + assert_success + assert_output --partial "Owner '" + assert_output --partial 'OrgOne/repo1' + assert_output --partial 'orgone/repo2' + refute_output --partial 'multiple accounts' +} + +@test "gh-record: workspace records reject newline values and keep metadata" { + run bash -c ' + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + export ACQ_PROVENANCE_DIR="'"$STUBDIR"'/provenance" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + acq_workspace_record_write sbx sb1 "'"$GWT"'/repoGH" + acq_workspace_record_write sbx sb1 "'"$GWT"'/repoGH +owner=evil" || true + acq_provenance_field sbx sb1 schema + acq_provenance_field sbx sb1 backend + acq_provenance_field sbx sb1 workspace_source + acq_workspace_record_read sbx sb1 + acq_provenance_field sbx sb1 owner + ' + assert_success + assert_line --index 0 '1' + assert_line --index 1 'sbx' + assert_line --index 2 'host' + assert_line --index 3 "$GWT/repoGH" + refute_output --partial 'evil' +} + +@test "gh-record: unmarked legacy workspace records are ignored" { + run bash -c ' + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + export ACQ_PROVENANCE_DIR="'"$STUBDIR"'/provenance" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + file=$(_acq_provenance_file sbx oldbox) + mkdir -p "$(dirname "$file")" + printf "schema=1\nbackend=sbx\nworkspace='"$GWT"'/repoGH\n" > "$file" + acq_workspace_record_read sbx oldbox + ' + assert_success + assert_output '' +} + +@test "gh-record: provenance refresh preserves only host-marked workspace" { + run bash -c ' + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + export ACQ_PROVENANCE_DIR="'"$STUBDIR"'/provenance" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + acq_workspace_record_write sbx goodbox "'"$GWT"'/repoGH" + acq_provenance_write sbx goodbox + printf "good=%s\n" "$(acq_workspace_record_read sbx goodbox)" + file=$(_acq_provenance_file sbx oldbox) + mkdir -p "$(dirname "$file")" + printf "schema=1\nbackend=sbx\nworkspace='"$GWT"'/repoGH\n" > "$file" + acq_provenance_write sbx oldbox + printf "legacy=%s\n" "$(acq_workspace_record_read sbx oldbox)" + ' + assert_success + assert_line --index 0 "good=$GWT/repoGH" + assert_line --index 1 'legacy=' +} + # advise_github_scope gate: fires iff repos present AND no sandbox-scoped github # secret (regardless of a global one). Force the file secret store into a unique # temp dir per invocation so planted secrets don't leak between cases. diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index bfd5adb5..da8d4041 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -192,6 +192,32 @@ _seed_usai() { assert [ -f "$STUBDIR/.msb_created" ] } +@test "create(msb): records the host workspace path for later github-scope" { + local proj="$STUBDIR/msb-gh-record" expected + mkdir -p "$proj" + expected=$(canonicalize_path "$proj") + rm -f "$STUBDIR/.msb_created" + run env USAI_API_KEY=sk-ci-host ACQ_BACKEND=msb "$ACQ" create opencode "$proj" + assert_success + assert [ -f "$STUBDIR/.msb_created" ] + run acq_workspace_record_read msb opencode-msb-gh-record + assert_success + assert_output "$expected" + refute_output --partial '/home/agent' +} + +@test "github-scope: no path does not read or persist guest-reported workspace" { + local proj="$STUBDIR/guest-controlled" + mkdir -p "$proj/repo" + ( cd "$proj/repo" && git init -q && git remote add origin https://github.com/GSA-TTS/guest.git ) + run env ACQ_BACKEND=sbx STUB_RECORDED_WORKSPACE="$proj" "$ACQ" github-scope oldbox + assert_failure + assert_output --partial 'no workspace path was provided' + refute_regex "$(cat "$CALLS")" 'sbx exec oldbox' + run acq_workspace_record_read sbx oldbox + assert_output '' +} + @test "run: present key + bad status -> post-create gate offers rotate; decline aborts" { local proj="$STUBDIR/keyrun2"; mkdir -p "$proj" _seed_usai