KB-755E

CI Sandbox Attestation Workflow Draft (B4′) — Phase 2 Offline MVP

19 min read Revision 1
tool-kiem-thuphase2b4-primesandboxcigithub-actionsattestationdraft

CI Sandbox Attestation Workflow Draft (B4′)

Status: CI_WORKFLOW_DRAFT_READY_NOT_APPLIED · Date: 2026-06-10 · Venue class: approved-equivalent CI runner (GitHub-hosted, ephemeral, NON-Mac-local) · Production mutation: NONE · Codex consulted: NO

This is a draft / ready-to-apply artifact. It is evidence-of-design, not authority. It is NOT applied to any repo, NOT triggered, and produces NO B4′ PASS by itself. Applying it requires an owner authorization decision (see §0 and the route-decision report reports/phase2-execution-substrate-and-route-decision-2026-06-10.md). It operationalizes the SSOT sandbox profile designs/deny-by-default-sandbox-profile-phase2-offline-mvp-2026-06-09.md; if anything here conflicts with that profile, the profile wins (CONFLICT → prefer the design SSOT).


0. Why this exists / what it changes

The prior canonical fix (checkpoints/operator-blocker-packet-sandbox-attestation-2026-06-09.md) required a human operator with docker run permission on the VPS (or an unspecified "approved CI runner"). This draft converts the CI option into a turnkey, command-free path: an authorized actor drops these files into a repo and clicks Run workflow. The GitHub-hosted runner builds the deny-by-default image, runs the 12 §6 probes inside the container, and uploads the §7 evidence bundle as an artifact. No VPS shell, no Mac-local Docker.

The one decision the owner still owns (it cannot be resolved by engineering evidence): which repo is authorized to host this attestation workflow. Options:

  • CI-A: owner approves creating a private repo (e.g. Huyen1974/tki-sandbox-attest, private) solely for attestation. The authenticated gh CLI (Huyen1974, scope repo+workflow) can create+push it once authorized.
  • CI-B: owner designates an existing approved repo/CI runner already inside the governed system to host it.
  • If neither is authorized → fall back to the VPS operator route (checkpoints/operator-execution-packet-phase2-sandbox-final-2026-06-10.md).

Until that authorization exists, this file stays a draft. No external publish has occurred.


1. Repo layout to apply

.
├── .github/workflows/b4-prime-sandbox-attestation.yml   # the workflow (§2)
├── Dockerfile.sandbox                                    # from the profile §3 (reproduced §4)
├── seccomp-deny-by-default.json                          # profile §4a targeted-deny (reproduced §5)
└── inspector/
    └── main.py                                           # attestation probe harness (§6)

All four files are below verbatim. No other repo content is required. The repo may be empty/private; the workflow has no push trigger (manual workflow_dispatch only) so it never runs by accident.


2. .github/workflows/b4-prime-sandbox-attestation.yml

name: B4' Sandbox Attestation (deny-by-default)

# Manual trigger ONLY. No push/PR triggers => no accidental runs, no auto-publish behaviour.
on:
  workflow_dispatch:
    inputs:
      run_date:
        description: "Attestation date stamp (YYYY-MM-DD)"
        required: true
        default: "2026-06-10"

permissions:
  contents: read          # least privilege; no write, no packages, no id-token

jobs:
  attest:
    runs-on: ubuntu-latest   # GitHub-hosted, ephemeral, NOT Mac-local; Docker preinstalled
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Record venue identity (proves NOT Mac-local)
        id: venue
        run: |
          echo "runner_os=$RUNNER_OS"            >> "$GITHUB_OUTPUT"
          echo "runner_arch=$RUNNER_ARCH"        >> "$GITHUB_OUTPUT"
          echo "image_os=$(uname -a)"            >> venue.txt
          echo "github_run_id=$GITHUB_RUN_ID"    >> venue.txt
          echo "github_repo=$GITHUB_REPOSITORY"  >> venue.txt
          docker --version                       >> venue.txt
          cat venue.txt

      - name: Build deny-by-default image
        run: docker build -f Dockerfile.sandbox -t tki-inspector:attest .

      - name: Capture image digest + seccomp hash + runtime
        id: meta
        run: |
          IMG_DIGEST=$(docker image inspect tki-inspector:attest --format '{{index .Id}}')
          SECCOMP_SHA=$(sha256sum seccomp-deny-by-default.json | awk '{print $1}')
          RUNTIME=$(docker --version | sed 's/,.*//')
          echo "image_digest=${IMG_DIGEST}"     >> "$GITHUB_OUTPUT"
          echo "seccomp_sha256=${SECCOMP_SHA}"  >> "$GITHUB_OUTPUT"
          echo "runtime=${RUNTIME}"             >> "$GITHUB_OUTPUT"

      - name: Prepare disposable in/out dirs
        run: |
          mkdir -p "$RUNNER_TEMP/in" "$RUNNER_TEMP/out"
          # read-only input fixture (empty packet is sufficient for boundary probes)
          echo '{"packet":"attestation-fixture"}' > "$RUNNER_TEMP/in/packet.json"
          chmod -R a-w "$RUNNER_TEMP/in" || true

      - name: Run probes INSIDE the deny-by-default container (§5 exact run command)
        run: |
          set +e
          docker run --rm \
            --name tki-sandbox-attest \
            --network none \
            --user 65532:65532 \
            --read-only \
            --tmpfs /tmp:rw,noexec,nosuid,nodev,size=16m \
            --cap-drop ALL \
            --security-opt no-new-privileges \
            --security-opt seccomp=seccomp-deny-by-default.json \
            --pids-limit 64 \
            --memory 256m --memory-swap 256m --cpus 1 \
            --env-file /dev/null \
            -v "$RUNNER_TEMP/in":/in:ro \
            -v "$RUNNER_TEMP/out":/out:rw \
            tki-inspector:attest --attest --run-date "${{ github.event.inputs.run_date }}"
          echo "container_exit=$?"
          set -e

      - name: Merge venue + image metadata into the evidence bundle
        run: |
          IN="$RUNNER_TEMP/out/sandbox-attestation-inside.json"
          OUT="$RUNNER_TEMP/out/sandbox-attestation-evidence-ci-${{ github.event.inputs.run_date }}.json"
          if [ ! -f "$IN" ]; then
            echo "::error::probe harness produced no inside-bundle — container may have failed-closed before writing /out"; exit 1
          fi
          python3 - "$IN" "$OUT" <<'PY'
          import json, sys, os
          inside = json.load(open(sys.argv[1]))
          inside["venue"] = "CI"
          inside["venue_identity"] = {
              "platform": "github-hosted-runner",
              "runner_os": os.environ.get("RUNNER_OS"),
              "runner_arch": os.environ.get("RUNNER_ARCH"),
              "github_repository": os.environ.get("GITHUB_REPOSITORY"),
              "github_run_id": os.environ.get("GITHUB_RUN_ID"),
              "not_mac_local": True,
          }
          inside["image_digest"]  = os.environ.get("IMAGE_DIGEST")
          inside["seccomp_sha256"]= os.environ.get("SECCOMP_SHA256")
          inside["runtime"]       = os.environ.get("RUNTIME")
          json.dump(inside, open(sys.argv[2], "w"), indent=2)
          print(open(sys.argv[2]).read())
          PY
        env:
          IMAGE_DIGEST: ${{ steps.meta.outputs.image_digest }}
          SECCOMP_SHA256: ${{ steps.meta.outputs.seccomp_sha256 }}
          RUNTIME: ${{ steps.meta.outputs.runtime }}

      - name: Upload evidence bundle artifact
        uses: actions/upload-artifact@v4
        with:
          name: b4-prime-sandbox-attestation-evidence
          path: |
            ${{ runner.temp }}/out/sandbox-attestation-evidence-ci-*.json
            ${{ runner.temp }}/out/sandbox-attestation-inside.json
          if-no-files-found: error
          retention-days: 30

Cleanup: none required — docker run --rm removes the container; the runner VM is destroyed at job end (GitHub-hosted runners are single-use). No persistent infra, no production touch.


3. How to apply, trigger, collect, verify

  1. Authorize a repo (see §0). With gh already authenticated as Huyen1974:
    # CI-A (only after owner authorization — this PUBLISHES the files to GitHub):gh repo create Huyen1974/tki-sandbox-attest --private --disable-issues --disable-wiki# populate the 4 files of §1, then:git add . && git commit -m "B4' sandbox attestation harness" && git push -u origin main
    
  2. Trigger: gh workflow run "B4' Sandbox Attestation (deny-by-default)" -f run_date=2026-06-10 (or the Actions UI → Run workflow).
  3. Collect: gh run download <run-id> -n b4-prime-sandbox-attestation-evidence → yields sandbox-attestation-evidence-ci-2026-06-10.json.
  4. Return to KB: place the bundle at reports/sandbox-attestation-evidence-ci-2026-06-10.json (KB) or VPS /opt/incomex/docs/mcp-writes/sandbox-attestation-ci-2026-06-10.json.
  5. Verify (follow-up agent, read-only): confirm every probe actual == §3-expected, bind to rev4 matrix #24–#37, then assert B4′ acceptance and run the gated build prompt.

4. Dockerfile.sandbox (reproduced from profile §3 — profile is SSOT)

FROM gcr.io/distroless/python3-debian12:nonroot
WORKDIR /app
COPY --chown=root:root inspector/ /app/
USER 65532:65532
ENTRYPOINT ["python", "/app/main.py"]

Distroless = no shell, no package manager → "no subprocess host binary" is structural, not merely seccomp-enforced.


5. seccomp-deny-by-default.json (profile §4a targeted-deny — first-attestation realization)

{
  "defaultAction": "SCMP_ACT_ALLOW",
  "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_AARCH64"],
  "syscalls": [
    {
      "names": [
        "socket","socketpair","connect","bind","listen","accept","accept4",
        "sendto","sendmsg","recvfrom","recvmsg","getpeername","getsockname",
        "execve","execveat","ptrace","process_vm_readv","process_vm_writev",
        "mount","umount2","pivot_root","chroot","setns","unshare","clone3",
        "init_module","finit_module","delete_module","kexec_load","reboot",
        "swapon","swapoff","bpf","perf_event_open"
      ],
      "action": "SCMP_ACT_ERRNO",
      "errnoRet": 1
    }
  ]
}

errnoRet: 1 = EPERM. The §4b hardened default-deny allowlist is a follow-on target and cannot be finalized without first running the real inspector (workload-specific); do not block first attestation on it.


6. inspector/main.py — attestation probe harness (runs INSIDE the container)

The harness runs all 12 §6 probes, records expected vs actual + errno, and writes /out/sandbox-attestation-inside.json (the workflow merges venue/image metadata afterwards). It never opens a network connection on purpose beyond the denied probe; it makes no live KB/PG read; it is self-contained stdlib only.

#!/usr/bin/env python3
# B4' attestation probe harness. Runs inside the deny-by-default container.
# Emits /out/sandbox-attestation-inside.json with the §7 probe schema.
import os, sys, json, socket, errno, ctypes

RUN_DATE = "unknown"
if "--run-date" in sys.argv:
    RUN_DATE = sys.argv[sys.argv.index("--run-date") + 1]

OUT = "/out/sandbox-attestation-inside.json"
SECRET_RE = None
import re
SECRET_RE = re.compile(r"(?i)(token|secret|password|passwd|_dsn|conn|^pg|^kb|api[_-]?key)")

probes = []

def record(pid, operation, expected, actual, errno_or_exit, verdict):
    probes.append({
        "probe_id": pid, "operation": operation, "expected": expected,
        "actual_stderr_or_value": str(actual)[:500], "errno_or_exit": errno_or_exit,
        "verdict": verdict, "artifact_path": OUT,
    })

# PR-NET-1: connect to a public resolver -> EPERM (socket denied) or ENETUNREACH/EAFNOSUPPORT
try:
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM); s.settimeout(2)
    s.connect(("1.1.1.1", 53))
    record("PR-NET-1", "connect 1.1.1.1:53", "EPERM/ENETUNREACH/EAFNOSUPPORT", "CONNECTED", 0, "FAIL")
except OSError as e:
    ok = e.errno in (errno.EPERM, errno.ENETUNREACH, errno.EAFNOSUPPORT)
    record("PR-NET-1", "connect 1.1.1.1:53", "EPERM(1)/ENETUNREACH(101)/EAFNOSUPPORT(97)", e, e.errno, "PASS" if ok else "FAIL")

# PR-NET-2: /proc/net/dev -> only 'lo'
try:
    ifaces = [ln.split(":")[0].strip() for ln in open("/proc/net/dev").read().splitlines()[2:]]
    ok = set(ifaces) <= {"lo"}
    record("PR-NET-2", "read /proc/net/dev", "interfaces == {lo}", ifaces, "n/a", "PASS" if ok else "FAIL")
except OSError as e:
    record("PR-NET-2", "read /proc/net/dev", "interfaces == {lo}", e, e.errno, "FAIL")

# PR-SOCK-1: raw AF_INET socket -> EPERM
try:
    socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    record("PR-SOCK-1", "socket(AF_INET,SOCK_STREAM)", "EPERM(1)", "CREATED", 0, "FAIL")
except OSError as e:
    record("PR-SOCK-1", "socket(AF_INET,SOCK_STREAM)", "EPERM(1)", e, e.errno, "PASS" if e.errno == errno.EPERM else "FAIL")

# PR-ENV-1: no secret-pattern env key
keys = sorted(os.environ.keys())
leaked = [k for k in keys if SECRET_RE.search(k)]
record("PR-ENV-1", "os.environ.keys()", "no token/secret/password/_dsn/conn/pg/kb/api_key", {"keys": keys, "leaked": leaked}, "n/a", "PASS" if not leaked else "FAIL")

# PR-FS-RO-IN: write into /in -> EROFS
try:
    open("/in/__probe", "w"); record("PR-FS-RO-IN", "open /in/__probe w", "EROFS(30)", "WROTE", 0, "FAIL")
except OSError as e:
    record("PR-FS-RO-IN", "open /in/__probe w", "EROFS(30)", e, e.errno, "PASS" if e.errno in (errno.EROFS, errno.EACCES) else "FAIL")

# PR-FS-ESC-1: write /etc -> EROFS (read-only rootfs)
try:
    open("/etc/__probe", "w"); record("PR-FS-ESC-1", "open /etc/__probe w", "EROFS(30)", "WROTE", 0, "FAIL")
except OSError as e:
    record("PR-FS-ESC-1", "open /etc/__probe w", "EROFS(30)", e, e.errno, "PASS" if e.errno in (errno.EROFS, errno.EACCES) else "FAIL")

# PR-FS-ESC-2: write /app -> EROFS/EACCES
try:
    open("/app/__probe", "w"); record("PR-FS-ESC-2", "open /app/__probe w", "EROFS(30)/EACCES(13)", "WROTE", 0, "FAIL")
except OSError as e:
    record("PR-FS-ESC-2", "open /app/__probe w", "EROFS(30)/EACCES(13)", e, e.errno, "PASS" if e.errno in (errno.EROFS, errno.EACCES) else "FAIL")

# PR-FS-OUT-OK: positive control — /out is the one writable path
try:
    open("/out/report.md", "w").write("ok")
    record("PR-FS-OUT-OK", "open /out/report.md w", "succeeds (positive control)", "WROTE ok", 0, "PASS")
except OSError as e:
    record("PR-FS-OUT-OK", "open /out/report.md w", "succeeds (positive control)", e, e.errno, "FAIL")

# PR-EXEC-1: execve real binary -> EPERM (seccomp); distroless also lacks /bin/ls
try:
    os.execv(sys.executable, [sys.executable, "-c", "pass"])
    record("PR-EXEC-1", "os.execv(python)", "EPERM(1) or FileNotFoundError", "EXECVE-SUCCEEDED", 0, "FAIL")
except OSError as e:
    ok = e.errno in (errno.EPERM, errno.ENOENT)
    record("PR-EXEC-1", "os.execv(python)", "EPERM(1)/ENOENT(2)", e, e.errno, "PASS" if ok else "FAIL")

# PR-MOUNT-1: mount table — rootfs ro, /in ro, /out rw, /tmp noexec, nothing else writable
try:
    mounts = open("/proc/self/mountinfo").read()
    has_in_ro  = any("/in" in ln and " ro" in ln for ln in mounts.splitlines())
    has_out_rw = any("/out" in ln and " rw" in ln for ln in mounts.splitlines())
    record("PR-MOUNT-1", "read /proc/self/mountinfo", "rootfs ro; /in ro; /out rw; /tmp noexec; nothing else",
           {"in_ro": has_in_ro, "out_rw": has_out_rw, "raw_present": True}, "n/a",
           "PASS" if (has_in_ro and has_out_rw) else "FAIL")
except OSError as e:
    record("PR-MOUNT-1", "read /proc/self/mountinfo", "expected mount table", e, e.errno, "FAIL")

# PR-SOCK-DOCKER: docker socket absent
exists = os.path.exists("/var/run/docker.sock")
record("PR-SOCK-DOCKER", "exists /var/run/docker.sock", "False", exists, "n/a", "PASS" if not exists else "FAIL")

# PR-PTRACE-1: ptrace(PTRACE_TRACEME) -> EPERM
try:
    libc = ctypes.CDLL("libc.so.6", use_errno=True)
    PTRACE_TRACEME = 0
    rc = libc.ptrace(PTRACE_TRACEME, 0, 0, 0)
    en = ctypes.get_errno()
    ok = (rc != 0 and en == errno.EPERM)
    record("PR-PTRACE-1", "ptrace(PTRACE_TRACEME)", "EPERM(1)", {"rc": rc, "errno": en}, en, "PASS" if ok else "FAIL")
except Exception as e:
    # libc may be unloadable in distroless; record UNVERIFIED rather than fake-PASS
    record("PR-PTRACE-1", "ptrace(PTRACE_TRACEME)", "EPERM(1)", f"UNVERIFIED: {e}", "n/a", "UNVERIFIED")

# raw context (observable only from inside)
def safe_read(p):
    try: return open(p).read()
    except OSError as e: return f"<unreadable: {e}>"

bundle = {
    "schema": "b4-prime-sandbox-attestation/inside/v1",
    "run_date": RUN_DATE,
    "probes": probes,
    "raw": {
        "mountinfo": safe_read("/proc/self/mountinfo"),
        "env_keyset": keys,
        "proc_net_dev": safe_read("/proc/net/dev"),
    },
    "summary": {
        "total": len(probes),
        "pass": sum(1 for p in probes if p["verdict"] == "PASS"),
        "fail": sum(1 for p in probes if p["verdict"] == "FAIL"),
        "unverified": sum(1 for p in probes if p["verdict"] == "UNVERIFIED"),
    },
}
os.makedirs("/out", exist_ok=True)
json.dump(bundle, open(OUT, "w"), indent=2)
print(json.dumps(bundle["summary"]))
# Fail-closed exit: nonzero if any probe FAILED, so the workflow surfaces a boundary breach.
sys.exit(0 if bundle["summary"]["fail"] == 0 else 2)

7. Probe → rev4 matrix binding (for the follow-up verification agent)

Probe rev4 matrix test Expected proof-of-block
PR-NET-1 / PR-NET-2 #27 EPERM/ENETUNREACH; ifaces={lo}
PR-SOCK-1 #25 sib EPERM on socket
PR-ENV-1 #28 no secret env key
PR-FS-RO-IN #33 EROFS on /in
PR-FS-ESC-1 / PR-FS-ESC-2 #29 EROFS/EACCES outside /out
PR-FS-OUT-OK #33 ctrl /out writable (positive control)
PR-EXEC-1 #34 / #25 EPERM(execve) / ENOENT
PR-MOUNT-1 #29/#33 mount table = {rootfs ro, /in ro, /out rw, /tmp noexec}
PR-SOCK-DOCKER #36 docker.sock absent
PR-PTRACE-1 #37 EPERM

#35 (PR-DYNIMPORT) is a build-time L2 check, not an OS probe — attested when the guard harness is built; flagged here so it is not silently dropped.


8. Honesty caveats (Article 14)

  • This draft produces no PASS. B4′ is PASS only after the bundle exists from an approved run and a follow-up agent verifies it against #24–#37.
  • The GitHub-hosted runner is an approved-equivalent isolation venue (ephemeral, non-Mac-local). Calling it "approved" is the owner's determination, not the agent's — see §0 / the route-decision report.
  • Applying CI-A publishes the harness to GitHub; that is outward-facing and is gated on explicit owner authorization. No publish has occurred.
  • If gcr.io/distroless/... or actions/* pinning policy differs in the target environment, pin to digests before applying. The seccomp architectures list assumes x86_64/aarch64 runners.
Back to Knowledge Hub knowledge/dev/laws/tool-kiem-thu/planning/ci-sandbox-attestation-workflow-draft-2026-06-10.md