Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions acq
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1404,7 +1406,18 @@ 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" ]; 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"
;;

Expand Down
85 changes: 77 additions & 8 deletions acq.backends/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1286,8 +1286,12 @@ _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 workspace_source
file=$(_acq_provenance_file "$backend" "$name") || return 1
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"
Expand All @@ -1304,6 +1308,10 @@ acq_provenance_write() {
printf 'applied_ref=%s\n' "$PATTERNS_KIT_REF"
printf 'backend=%s\n' "$backend"
printf 'applied_at=%s\n' "$ts"
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"
Expand All @@ -1325,6 +1333,47 @@ 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
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.$$"
{
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() {
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.
# Echoes exactly one of: current | stale | unknown.
# current — record exists and applied_ref == local PATTERNS_KIT_REF.
Expand Down Expand Up @@ -2086,11 +2135,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_key owner_count=0

repos=$(detect_workspace_repos "$ws")
if [ -z "$repos" ]; then
Expand All @@ -2102,16 +2153,33 @@ github_scope_sandbox() {
while IFS= read -r nwo; do
[ -n "$nwo" ] || continue
owner="${nwo%%/*}"
case "$owners" in *"|$owner|"*) ;; *) owners="$owners|$owner|" ;; 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 <<EOF
$repos
EOF

if [ "$owner_count" -gt 1 ]; then
echo "acq: cannot create a repo-scoped GitHub token for sandbox '$sandbox'." >&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 <<EOF
$repos
EOF
echo "" >&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
Expand All @@ -2126,6 +2194,7 @@ EOF
IFS="$_oldifs"
for o in "$@"; do
[ -n "$o" ] || continue
o="${o#*=}"
echo "" >&2
echo " Owner '$o' — open:" >&2
printf ' %s\n' "$(_acq_github_pat_url "$o" "$sandbox")" >&2
Expand Down Expand Up @@ -2202,7 +2271,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
Expand Down
15 changes: 12 additions & 3 deletions acq.backends/msb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3033,7 +3033,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.
Expand Down Expand Up @@ -3066,15 +3066,23 @@ 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
_whost="$_wsrc"
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
Expand Down Expand Up @@ -3335,6 +3343,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" "$_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
Expand Down
1 change: 1 addition & 0 deletions acq.backends/sbx.sh
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,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.
Expand Down
6 changes: 4 additions & 2 deletions docs/adr/0013-per-sandbox-github-token-downscoping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 5 additions & 3 deletions docs/howto/acq.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,11 @@ the guest.
> ./acq github-scope <sandbox-name> /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 <sandbox-name>` 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
Expand Down
7 changes: 4 additions & 3 deletions docs/howto/sbx.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,16 +160,17 @@ 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 <sandbox-name> /path/to/your/project
> ```
>
> 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:

Expand Down
3 changes: 3 additions & 0 deletions scripts/test-acq-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions test/bats/116-clone-option.bats
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ _refute_backend_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"
Expand Down
12 changes: 12 additions & 0 deletions test/bats/117-msys-path-forms.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 '
Expand Down
Loading
Loading