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
127 changes: 127 additions & 0 deletions .github/workflows/agent-implement.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,130 @@ jobs:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: pnpm sandcastle:implement

# REDACT BEFORE UPLOAD — this repository is PUBLIC.
#
# Actions masks registered secrets in STEP LOGS, but it does NOT mask
# ARTIFACT CONTENTS, and artifacts on a public repo are downloadable by
# anyone. The agent runs with GH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN in its
# container environment, so any command it happens to run that echoes the
# environment (`env`, `git config --list`, a verbose curl) would put a live
# credential into the log we are about to publish.
#
# Ported from connector's proven agent-implement.yml (the #462 sweep), with
# one buzz-specific addition: Nostr secret keys (`nsec1...`) are redacted in
# the shape pass — buzz agents handle Nostr identities, and a bech32 nsec in
# a log is a live credential just like a token.
#
# Three passes, because no one of them is sufficient:
# 1. Exact values of the secrets this job holds — reliable, catches any
# shape including ones we have not anticipated.
# 2. Known token/key shapes — catches credentials this job never held,
# e.g. one the agent minted or read from somewhere else mid-run.
# 3. BIP-39 mnemonics and labelled private keys — defence in depth for
# key material an agent generates or handles mid-run.
# Secrets are passed via env and never echoed.
#
# RESIDUAL GAP, stated rather than hidden: a raw 32-byte private key is
# 64 hex chars, indistinguishable in shape from an event id or hash.
# Redacting that shape unconditionally would strip the ids that make a log
# worth reading, so pass 3 only redacts it when it appears against a key-ish
# label (`privateKey`, `secret_key`, ...). An unlabelled bare hex key would
# survive. Do not treat these artifacts as safe to publish a secret through;
# the label gate is the real control, and this is defence in depth.
- name: Redact credentials from agent logs
if: always()
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
run: |
set -euo pipefail
[ -d .sandcastle/logs ] || { echo "No logs directory — nothing to redact."; exit 0; }
python3 - <<'PY'
import os, pathlib, re

# Pass 1: exact secret values held by this job (longest first, so a
# secret that contains another is not partially replaced).
exact = sorted(
(v for v in (os.environ.get(k, "") for k in
("GH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "APP_PRIVATE_KEY"))
if v and len(v.strip()) >= 8),
key=len, reverse=True,
)

# Passes 2 and 3, as (pattern, replacement) so a rule can keep the
# context it matched on.
patterns = [
# Pass 2 — token/key shapes, whether or not this job ever held them.
(re.compile(r"gh[pousr]_[A-Za-z0-9]{16,}"), "***REDACTED***"),
(re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "***REDACTED***"),
(re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}"), "***REDACTED***"),
(re.compile(r"nsec1[a-z0-9]{20,}"), "***REDACTED***"),
(re.compile(r"-----BEGIN[^-]*PRIVATE KEY-----.*?-----END[^-]*PRIVATE KEY-----",
re.DOTALL), "***REDACTED***"),
# Pass 3b — a raw private key is 64 hex chars, the same shape as an
# event id, so it is redacted only next to a key-ish label, and the
# label is kept so the log still reads. See the residual gap noted
# on this step.
(re.compile(
r"((?:private[_\-]?key|secret[_\-]?key|signer[_\-]?key|priv[_\-]?key|keyId)"
r"[\"'\s:=]{0,6})(?:0x)?[0-9a-fA-F]{64}",
re.IGNORECASE,
), r"\1***REDACTED***"),
]

# Pass 3a — BIP-39 mnemonics: a run of exactly 12/15/18/21/24 lowercase
# words. Matching on structure rather than on the 2048-word list keeps
# this step dependency-free; the cost is that 12+ consecutive words of
# ordinary lowercase prose are caught too. That trade is deliberate —
# over-redacting a sentence in an agent log is cheap, publishing a seed
# is not.
#
# Longest first: with 12 tried first, a 24-word phrase would have its
# leading 12 words consumed and the remaining 12 left in the clear.
for n in (24, 21, 18, 15, 12):
patterns.append((
re.compile(r"(?<![A-Za-z0-9'\-])(?:[a-z]{3,8}[ \n]+){%d}[a-z]{3,8}(?![A-Za-z0-9'\-])"
% (n - 1)),
"***REDACTED***",
))

hits = 0
for path in pathlib.Path(".sandcastle/logs").rglob("*"):
if not path.is_file():
continue
raw = path.read_text(encoding="utf-8", errors="replace")
out = raw
for value in exact:
for form in {value, value.strip(), value.replace("\n", "\\n")}:
if form and form in out:
out = out.replace(form, "***REDACTED***")
for pat, repl in patterns:
out = pat.sub(repl, out)
if out != raw:
hits += 1
path.write_text(out, encoding="utf-8")
# Count only — never print what was found.
print(f"Redaction complete. Files modified: {hits}")
PY

# The agent's own reasoning is written to .sandcastle/logs/, NOT to the step
# output — the step log only says "Started on branch ..." and then, minutes
# later, the outcome. When a run ends with the fail-loud "open-pr reported
# COMPLETE but no PR exists" (exactly what happened on this repo's first
# live run, issue #56) there is otherwise NOTHING to diagnose from: the
# open-pr transcript dies with the runner. Upload it so a failed or empty
# run is explicable.
#
# `if: always()` because the interesting cases are exactly the ones where
# the previous step failed.
- name: Upload agent logs
if: always()
uses: actions/upload-artifact@v4
with:
name: sandcastle-implement-logs-issue-${{ github.event.issue.number }}
path: |
.sandcastle/logs/
if-no-files-found: warn
retention-days: 14
15 changes: 14 additions & 1 deletion .sandcastle/agent-implement-issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,20 @@ const hooks = {
// shells out to `gh auth git-credential`, which reads GH_TOKEN at push
// time. Guarded on GH_TOKEN so local dev without a token no-ops instead
// of aborting sandbox setup (onSandboxReady failures are fatal).
{ command: 'if [ -n "$GH_TOKEN" ]; then gh auth setup-git; fi' },
//
// The `--unset-all http.<github>.extraheader` that follows is LOAD-BEARING
// (org-wide pattern; its absence here is what broke this repo's first live
// run on issue #56): actions/checkout persists an `AUTHORIZATION: basic`
// extraheader carrying the workflow's READ-ONLY job token in the repo-local
// git config, the engine bind-mounts the whole `.git` into the sandbox, and
// an explicit header BEATS any credential helper — so without the unset,
// the in-sandbox `git push` authenticates as the read-only token and is
// rejected, while `gh` API calls (which read GH_TOKEN directly) still work.
{
command:
'if [ -n "$GH_TOKEN" ]; then gh auth setup-git; ' +
"git config --unset-all 'http.https://github.com/.extraheader' 2>/dev/null || true; fi",
},
{ command: "pnpm install --frozen-lockfile" },
],
},
Expand Down
10 changes: 8 additions & 2 deletions .sandcastle/agent-review-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ const hooks = {
// setup-git` installs `gh` as git's credential helper (reads GH_TOKEN at
// push time, stores no token in any file). Guarded on GH_TOKEN so
// token-less local dev no-ops rather than aborting setup. See
// ./agent-implement-issue.ts for the full root-cause note.
{ command: 'if [ -n "$GH_TOKEN" ]; then gh auth setup-git; fi' },
// ./agent-implement-issue.ts for the full root-cause note, including why
// the extraheader unset is load-bearing (actions/checkout persists a
// read-only-token header that beats the credential helper).
{
command:
'if [ -n "$GH_TOKEN" ]; then gh auth setup-git; ' +
"git config --unset-all 'http.https://github.com/.extraheader' 2>/dev/null || true; fi",
},
{ command: "pnpm install --frozen-lockfile" },
],
},
Expand Down
10 changes: 8 additions & 2 deletions .sandcastle/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,14 @@ const hooks = {
// only succeeds by luck. `gh auth setup-git` installs `gh` as git's
// credential helper (reads GH_TOKEN at push time, stores no token in any
// file). Guarded on GH_TOKEN so token-less local dev no-ops rather than
// aborting setup. See ./agent-implement-issue.ts for the full note.
{ command: 'if [ -n "$GH_TOKEN" ]; then gh auth setup-git; fi' },
// aborting setup. See ./agent-implement-issue.ts for the full note,
// including why the extraheader unset is load-bearing (actions/checkout
// persists a read-only-token header that beats the credential helper).
{
command:
'if [ -n "$GH_TOKEN" ]; then gh auth setup-git; ' +
"git config --unset-all 'http.https://github.com/.extraheader' 2>/dev/null || true; fi",
},
{ command: "pnpm install --frozen-lockfile" },
],
},
Expand Down
Loading