STACK: allow GitHub issue reads and block issue creation
Prepare a narrowly scoped STACK GitHub Passport that permits one issue-list read while denying issue creation, with the submitted runner contract verified offline and live provider enforcement kept as a separate credentialed check.
Run this workflow
CI-verified, 6/6 fixtures passing.
Build this with your agent
One copy-paste hands Claude Code, Codex, or Cursor the full recipe, steps included, nothing to fetch.
Intended Use
A disposable GitHub repository and a deliberately narrow STACK connection where an operator wants to demonstrate that the selected standard-mode agent can list issues through STACK but cannot create one. First run the keyless FlowStacks gate against the immutable example; then, after reviewing current STACK documentation and the exact grant and Passport constraints, run the live check with short-lived credentials and inspect both evidence surfaces.
Not for
- Treating the green offline gate as proof that STACK or GitHub enforced the policy live; FlowStacks does not hold STACK credentials or contact either authenticated API
- Running against a production or valuable repository: the live example deliberately attempts issue creation and tells the operator to stop and inspect the repository if the write is not blocked
- Sending an operator token or Passport to any API origin other than exactly https://api.getstack.run; loopback development is only for an intentional STACK_ALLOW_LOCAL_DEVELOPMENT=1 test setup
- Granting broad GitHub write access and assuming the Passport alone removes the underlying connection's risk; constrain and review both the current grant and the Passport
- Treating locally decoded JWT claims as authentication: the runner uses them only for fail-closed preflight checks and relies on STACK to verify the Passport
- Claiming STACK is an operating-system sandbox or that an agent cannot bypass it through separate network paths, credentials, tools, or direct GitHub access
- Relying on stale endpoint, pricing, retention, or entitlement details; confirm the current STACK documentation and account terms before a live run
The Stack
Tested Against
submitted Gist@57f804242239a5151152b67786783714385d6160run.py@sha256:a9a65004badb74342c1775228c0b305eaa15016d9fe8a600b72de6a702bb93betest_run.py@sha256:27608cc61aea1a5c215e8fee0a03925ad8dff95bb9537c77e8d0bade95e177c9Python@3.12STACK public API documentation@reviewed 2026-10-08Side effects & data flow
- Network
- gist.githubusercontent.com only while CI downloads the four immutable public source files, api.getstack.run during the separate live run; STACK then proxies the permitted GitHub read and is expected to reject the write before GitHub
- Writes
- ./source/, ./upstream-tests.txt, and ./missing-credentials.txt inside the disposable CI directory, STACK audit and security-event records during a live run; if the policy is misconfigured, one clearly labeled issue may be created in the disposable GitHub repository
- Credentials
- None for FlowStacks CI, STACK_API_TOKEN, STACK_PASSPORT_TOKEN, STACK_AGENT_ID, and STACK_GITHUB_REPO for the separate live check; the connected GitHub credential remains managed by STACK
Data privacy
- GitHub Gist delivery ← CI requests the four public files at one immutable revision; no user content or credential is sent (retention: per GitHub's published policies)
- STACK ← During a live run, the repository identifier, issue-list request and response, denied issue title, agent and Passport identifiers, and operational evidence pass through or are recorded by STACK (retention: per the current STACK plan and privacy policy)
- GitHub ← The permitted issue-list read; the issue-creation request should not reach GitHub, but may do so if the live control is misconfigured (retention: per the repository owner and GitHub account policies)
Prerequisites
- Python 3, curl, and Node.js for the keyless FlowStacks verification gate
- For the separate live check: a current STACK account and plan that supports the required agent, connection, Passport, proxy, audit, and security-event operations
- A short-lived STACK operator token, scoped Passport token, standard-mode agent ID, and a STACK-managed GitHub connection
- A disposable owner/repository target whose issue list may be read and where a failed policy could safely create one clearly labeled test issue
- Human review of the current STACK grant, Passport claims, hosted API documentation, audit output, and the disposable GitHub repository after the run
Steps
- 1
Pin and verify the submitted runner without credentials
Download the four files from immutable Gist revision 57f8042, enforce exact sizes and SHA-256 digests, verify the MIT notice and SPDX headers, audit the Python imports and origin guard, run all 10 upstream fake-transport tests in an empty environment, and independently probe origin lookalikes, loopback opt-in, redirect refusal, and pre-network failures. This step never contacts STACK or GitHub's authenticated API.
set -eu mkdir source curl --proto '=https' --tlsv1.2 --retry 3 --max-filesize 65536 -fsSL https://gist.githubusercontent.com/randomUsername-01/01e2d14c92085d196342e7c275c6051c/raw/57f804242239a5151152b67786783714385d6160/LICENSE -o source/LICENSE curl --proto '=https' --tlsv1.2 --retry 3 --max-filesize 65536 -fsSL https://gist.githubusercontent.com/randomUsername-01/01e2d14c92085d196342e7c275c6051c/raw/57f804242239a5151152b67786783714385d6160/README.md -o source/README.md curl --proto '=https' --tlsv1.2 --retry 3 --max-filesize 65536 -fsSL https://gist.githubusercontent.com/randomUsername-01/01e2d14c92085d196342e7c275c6051c/raw/57f804242239a5151152b67786783714385d6160/run.py -o source/run.py curl --proto '=https' --tlsv1.2 --retry 3 --max-filesize 65536 -fsSL https://gist.githubusercontent.com/randomUsername-01/01e2d14c92085d196342e7c275c6051c/raw/57f804242239a5151152b67786783714385d6160/test_run.py -o source/test_run.py node <<'NODE' const crypto = require("crypto"); const fs = require("fs"); function bad(message) { console.error("BAD: " + message); process.exit(1); } const expected = { "LICENSE": [1070, "fd18c7e0d5d04654b6c05d0de6f7f2226e4284c1ab3655a0602e1181eab74e43"], "README.md": [7179, "3a5454d248de5a644532ef79b3033b3a69a911ac8f6d341be0ffeae94488f4f7"], "run.py": [12374, "a9a65004badb74342c1775228c0b305eaa15016d9fe8a600b72de6a702bb93be"], "test_run.py": [7114, "27608cc61aea1a5c215e8fee0a03925ad8dff95bb9537c77e8d0bade95e177c9"], }; const names = fs.readdirSync("source").sort(); if (JSON.stringify(names) !== JSON.stringify(Object.keys(expected).sort())) bad("source file set changed"); for (const [name, [size, digest]] of Object.entries(expected)) { const data = fs.readFileSync("source/" + name); if (data.length !== size) bad(name + " size changed"); if (crypto.createHash("sha256").update(data).digest("hex") !== digest) bad(name + " digest changed"); } const license = fs.readFileSync("source/LICENSE", "utf8"); const runner = fs.readFileSync("source/run.py", "utf8"); const tests = fs.readFileSync("source/test_run.py", "utf8"); if (!license.includes("MIT License") || !license.includes("Copyright (c) 2026 Tomi Lupsakko")) bad("MIT notice changed"); if (!runner.includes("SPDX-License-Identifier: MIT") || !tests.includes("SPDX-License-Identifier: MIT")) bad("SPDX headers missing"); if (!runner.includes('PRODUCTION_ORIGIN = "https://api.getstack.run"')) bad("production origin guard changed"); if (!runner.includes('os.environ.get("STACK_ALLOW_LOCAL_DEVELOPMENT") == "1"')) bad("local development opt-in changed"); console.log("source pinned OK: Gist 57f8042, four MIT files, exact sizes and SHA-256 digests"); NODE PYTHON=$(command -v python3) env -i PYTHONDONTWRITEBYTECODE=1 "$PYTHON" -B <<'PY' import ast from pathlib import Path allowed = { "run.py": {"__future__", "base64", "json", "os", "re", "secrets", "sys", "dataclasses", "typing", "urllib.error", "urllib.parse", "urllib.request"}, "test_run.py": {"base64", "io", "json", "unittest", "contextlib", "run"}, } for name, expected in allowed.items(): tree = ast.parse(Path("source", name).read_text(), filename=name) found = set() for node in ast.walk(tree): if isinstance(node, ast.Import): found.update(alias.name for alias in node.names) elif isinstance(node, ast.ImportFrom): found.add(node.module or "") if found != expected: raise SystemExit(f"unexpected imports in {name}: {sorted(found - expected)}; missing: {sorted(expected - found)}") print("runner boundary OK: audited Python standard library only; exact production origin; loopback requires explicit opt-in") PY (cd source && env -i PYTHONDONTWRITEBYTECODE=1 "$PYTHON" -B -m unittest discover -p 'test_*.py' -v) > upstream-tests.txt 2>&1 cat upstream-tests.txt grep -q "Ran 10 tests" upstream-tests.txt grep -q '^OK$' upstream-tests.txt echo "offline runner tests OK: 10/10" env -i PYTHONDONTWRITEBYTECODE=1 "$PYTHON" -B <<'PY' import contextlib import io import os import sys from unittest.mock import patch sys.path.insert(0, "source") import run as demo accepted = ["https://api.getstack.run", "https://api.getstack.run/"] for url in accepted: if demo.StackClient(url, "operator", "passport").base_url != demo.PRODUCTION_ORIGIN: raise SystemExit("production origin was not normalized") rejected = [ "https://evil.example", "https://api.getstack.run.evil.example", "https://api.getstack.run:8443", "https://api.getstack.run/v1", "https://api.getstack.run.", "https://api.getstack.run//v1", "http://2130706433:3001", "https://localhost.evil.example", ] for url in rejected: try: demo.StackClient(url, "operator", "passport", allow_local=True) except demo.DemoError: pass else: raise SystemExit("untrusted origin accepted: " + url) for url in ["http://localhost:3001", "http://127.0.0.1:3001", "http://[::1]:3001"]: try: demo.StackClient(url, "operator", "passport") except demo.DemoError: pass else: raise SystemExit("loopback accepted without opt-in: " + url) demo.StackClient(url, "operator", "passport", allow_local=True) if demo.NoRedirects().redirect_request(None, None, 302, "redirect", {}, "https://evil.example") is not None: raise SystemExit("redirect handler no longer refuses redirects") evil_env = { "STACK_API_TOKEN": "operator", "STACK_PASSPORT_TOKEN": "header.e30.signature", "STACK_AGENT_ID": "agt_example", "STACK_GITHUB_REPO": "owner/repo", "STACK_API_BASE_URL": "https://evil.example", } stderr = io.StringIO() with patch.dict(os.environ, evil_env, clear=True), contextlib.redirect_stderr(stderr): status = demo.main() if status != 1 or "must be exactly https://api.getstack.run" not in stderr.getvalue(): raise SystemExit("main did not reject the untrusted origin before a request") print("origin regression OK: production exact; lookalikes rejected; loopback opt-in narrow; redirects refused") PY set +e (cd source && env -i PYTHONDONTWRITEBYTECODE=1 "$PYTHON" -B run.py) > missing-credentials.txt 2>&1 STATUS=$? set -e if [ "$STATUS" -ne 2 ]; then cat missing-credentials.txt echo "BAD: missing credentials did not exit 2" >&2 exit 1 fi grep -q "Missing environment variables: STACK_API_TOKEN, STACK_PASSPORT_TOKEN, STACK_AGENT_ID, STACK_GITHUB_REPO" missing-credentials.txt echo "missing-credentials OK: runner exits 2 before network when required variables are absent" - 2
Create the narrow live grant and Passport in a disposable environment
Using the current STACK interface and documentation, connect only a disposable GitHub repository, select a standard-mode agent, and constrain both the current GitHub grant and its Passport to method GET plus the exact /repos/OWNER/REPOSITORY/issues path. Keep authority bindings and missions out of this repeatable example, choose short-lived credentials, and inspect the decoded claims without treating local decoding as signature verification.
- 3
Run the live denial check, inspect both evidence surfaces, and revoke
Set the four required variables and leave STACK_API_BASE_URL unset so the runner uses exactly https://api.getstack.run. Execute run.py only against the disposable repository. Require the issue-list read to return GitHub 200 through STACK, the creation attempt to return STACK 403 for the method constraint, a matching credential.proxy success in the audit log, and a matching credential_outside_scope security event. Then inspect GitHub for an unexpected issue, revoke the Passport and test credentials, and retain only non-sensitive evidence. Do not use STACK_ALLOW_LOCAL_DEVELOPMENT in production.
Eval, 6 fixtures
Last passed: verified todaysource-pinnedcontainstimeout 120s · max $0Expected:
source pinned OK: Gist 57f8042, four MIT files, exact sizes and SHA-256 digestsrunner-boundarycontainstimeout 120s · max $0Expected:
runner boundary OK: audited Python standard library only; exact production origin; loopback requires explicit opt-inupstream-testscontainstimeout 120s · max $0Expected:
offline runner tests OK: 10/10origin-regressioncontainstimeout 120s · max $0Expected:
origin regression OK: production exact; lookalikes rejected; loopback opt-in narrow; redirects refusedmissing-credentialscontainstimeout 120s · max $0Expected:
missing-credentials OK: runner exits 2 before network when required variables are absentclean-exitexit_codetimeout 120s · max $0Expected:
0
Results
FlowStacks pins all four files from Gist revision 57f8042, verifies their exact sizes and SHA-256 digests, checks the MIT notice and SPDX headers, rejects imports outside the audited Python standard-library boundary, runs the submitter's 10 offline tests in an empty environment, independently exercises production-origin lookalikes and the explicit loopback-only development opt-in, and proves missing credentials and an untrusted API origin fail before any request. This verifies the MIT runner by Tomi Lupsakko, not STACK's hosted policy enforcement or a live GitHub denial.
Did this work for you?
Our CI checks the setup runs. You tell us if the whole thing worked. Tell us straight.
Related workflows
- Buzz + Hermes over ACP: prove channel scope bounds what an agent reads, because signing is provenance not authorization
- n8n as an MCP server: prove each exposed workflow is one narrow tool behind its own token
- Shepherd: prove an agent task is retained and least-privilege before it runs
- Vet the fine print a star count hides: real license and a gate on dual-use tools
- Agent-Reach: throwaway account, least privilege, scan before install
- Vet a SKILL.md before you install it
Liked this workflow?
Get new verified workflows in WebAfterAI, three issues a week (Tue, Thu, Sat).