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
241 changes: 241 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# Two-stage prerelease pipeline (ADR-0052 era; see docs/release-process.md).
#
# Stage A (test / compat / build-and-verify): runs on manual dispatch or a
# prerelease tag push. Builds once from a clean checkout, runs the full test +
# client-compatibility matrix, packs every workspace, smoke-tests the packed
# artifacts in a fresh project, and uploads immutable tarballs + checksums +
# SBOM + a release manifest. Performs NO publication.
#
# Stage B (publish): runs only on workflow_dispatch with publish=true AND the
# protected `npm-release` environment's approval. Downloads Stage A's exact
# artifacts, verifies checksums / tag / declared versions, refuses to overwrite
# any existing version, publishes in dependency order with provenance, verifies
# via `npm view` + a clean registry install, and only then creates the GitHub
# prerelease.
#
# Auth: prefers npm trusted publishing (GitHub Actions OIDC) — configure it on
# npmjs.com for each package and leave NPM_TOKEN unset; npm ≥11.5 detects it
# automatically. Until then, set the NPM_TOKEN secret ON THE npm-release
# ENVIRONMENT (never repo-wide) so it is unreachable from PR workflows.
name: release

on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (must already point at this commit for Stage B)"
required: true
default: "v0.1.0-alpha.1"
publish:
description: "Run Stage B (publish to npm + GitHub prerelease)"
type: boolean
default: false
push:
tags: ["v*-alpha*", "v*-beta*", "v*-rc*"]

permissions: {}

env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}

jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
matrix:
node: ["22", "24"] # Maintenance LTS + Active LTS
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: ${{ matrix.node }}
cache: npm
- run: sudo apt-get update && sudo apt-get install -y bubblewrap
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true # Ubuntu 24.04 userns mitigation; the "no silent skip" test is the real gate
- run: npm ci
- run: npm run build
- run: npm run fixtures
- run: npm run benchmark:publish
- run: npm test

compat:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm install --global pnpm@11.11.0 bun@1.3.12
- run: npm run fixtures
- run: npm run build
- run: npm run compat:clients
- run: npm install --prefix "$RUNNER_TEMP/verdaccio" --ignore-scripts verdaccio@6.7.4
- run: node --import tsx --test packages/proxy/test/registry-migration.test.ts
env:
SENTINEL_VERDACCIO_BIN: ${{ runner.temp }}/verdaccio/node_modules/.bin/verdaccio

build-and-verify:
needs: [test, compat]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm run build
- name: Pack every workspace and smoke-test the packed artifacts in a fresh project
run: npx tsx scripts/release-smoke.ts --pack-dest release-artifacts --json release-artifacts/smoke-results.json
- name: Verify every tarball declares the release version
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
for t in release-artifacts/*.tgz; do
declared="$(tar -xzOf "$t" package/package.json | jq -r .version)"
if [ "$declared" != "$VERSION" ]; then
echo "::error::$t declares $declared, expected $VERSION"; exit 1
fi
done
echo "all tarballs declare $VERSION"
- name: SBOM (CycloneDX, production dependency tree)
run: npm sbom --sbom-format cyclonedx --omit dev > release-artifacts/sbom.cdx.json
- name: Checksums + release manifest
run: |
set -euo pipefail
cd release-artifacts
sha256sum *.tgz > SHA256SUMS
jq -n \
--arg commit "$GITHUB_SHA" \
--arg tag "$RELEASE_TAG" \
--arg run "$GITHUB_RUN_ID" \
--rawfile sums SHA256SUMS \
'{commit: $commit, tag: $tag, workflow_run: $run, checksums: $sums}' > release-manifest.json
cat SHA256SUMS release-manifest.json
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: release-artifacts
path: release-artifacts/
if-no-files-found: error

publish:
# Stage B: requires BOTH the explicit publish=true dispatch input AND a
# reviewer approving the protected npm-release environment.
if: github.event_name == 'workflow_dispatch' && inputs.publish
needs: build-and-verify
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: write # create the GitHub prerelease
id-token: write # npm provenance / trusted publishing (OIDC)
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Verify the tag exists and points at the tested commit
run: |
set -euo pipefail
git fetch origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" || {
echo "::error::tag ${RELEASE_TAG} not found on origin — push it (pointing at $GITHUB_SHA) before approving Stage B"; exit 1; }
tagged="$(git rev-parse "${RELEASE_TAG}^{commit}")"
if [ "$tagged" != "$GITHUB_SHA" ]; then
echo "::error::tag ${RELEASE_TAG} points at $tagged, but this workflow tested $GITHUB_SHA"; exit 1
fi
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-artifacts
path: release-artifacts/
- name: Verify checksums and declared versions
run: |
set -euo pipefail
cd release-artifacts
sha256sum -c SHA256SUMS
VERSION="${RELEASE_TAG#v}"
for t in *.tgz; do
declared="$(tar -xzOf "$t" package/package.json | jq -r .version)"
[ "$declared" = "$VERSION" ] || { echo "::error::$t declares $declared, expected $VERSION"; exit 1; }
done
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Refuse to republish any existing version
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
for p in core proxy sandbox mcp steward cli action; do
if npm view "@git-agentic/sentinel-$p@$VERSION" version >/dev/null 2>&1; then
echo "::error::@git-agentic/sentinel-$p@$VERSION already exists on the registry — refusing to continue"; exit 1
fi
done
echo "no target version exists — safe to publish"
- name: Publish in dependency order (--access public --tag alpha --provenance)
env:
# Empty when npm trusted publishing (OIDC) is configured — preferred.
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
published=""
for p in core proxy sandbox mcp steward cli action; do
t="release-artifacts/git-agentic-sentinel-$p-$VERSION.tgz"
echo "publishing @git-agentic/sentinel-$p@$VERSION"
if ! npm publish "$t" --access public --tag alpha --provenance; then
echo "::error::publish of @git-agentic/sentinel-$p failed. Already published (immutable): ${published:-none}. Do NOT unpublish; fix forward."; exit 1
fi
published="$published @git-agentic/sentinel-$p"
echo "- @git-agentic/sentinel-$p@$VERSION published" >> "$GITHUB_STEP_SUMMARY"
done
- name: Verify every published package via npm view
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
for p in core proxy sandbox mcp steward cli action; do
for i in 1 2 3 4 5; do
got="$(npm view "@git-agentic/sentinel-$p@$VERSION" version 2>/dev/null || true)"
[ "$got" = "$VERSION" ] && break
sleep 15
done
[ "$got" = "$VERSION" ] || { echo "::error::@git-agentic/sentinel-$p@$VERSION not visible after publish"; exit 1; }
tag_alpha="$(npm view "@git-agentic/sentinel-$p" dist-tags.alpha)"
[ "$tag_alpha" = "$VERSION" ] || { echo "::error::@git-agentic/sentinel-$p dist-tag alpha is $tag_alpha, expected $VERSION"; exit 1; }
echo "@git-agentic/sentinel-$p@$VERSION visible, dist-tag alpha OK"
done
- name: Install the published packages from the public registry in a clean project
run: |
set -euo pipefail
VERSION="${RELEASE_TAG#v}"
dir="$(mktemp -d)"
cd "$dir"
npm init -y >/dev/null
# retry: registry propagation can lag the view endpoint
for i in 1 2 3 4 5; do
npm install --no-audit --no-fund \
"@git-agentic/sentinel-core@$VERSION" "@git-agentic/sentinel-proxy@$VERSION" "@git-agentic/sentinel-sandbox@$VERSION" \
"@git-agentic/sentinel-mcp@$VERSION" "@git-agentic/sentinel-steward@$VERSION" "@git-agentic/sentinel-cli@$VERSION" \
"@git-agentic/sentinel-action@$VERSION" && break
sleep 20
done
./node_modules/.bin/sentinel --version | grep -qx "$VERSION"
node -e "import('@git-agentic/sentinel-core').then(m=>{if(m.ENGINE_VERSION!=='$VERSION')process.exit(1)})"
echo "clean-project registry install OK"
- name: Create the GitHub prerelease (only after npm verification)
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" \
--prerelease \
--verify-tag \
--title "Sentinel $RELEASE_TAG" \
--notes-file "docs/releases/${RELEASE_TAG}.md" \
release-artifacts/*.tgz \
release-artifacts/SHA256SUMS \
release-artifacts/sbom.cdx.json \
release-artifacts/release-manifest.json
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ policy/keys/
# macOS
.DS_Store
**/.DS_Store

# missing-Landlock-helper test scratch (packages/sandbox)
.nohelper-*/
35 changes: 20 additions & 15 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,17 @@ package is installed and approved is out of scope.

Seven packages (npm workspaces monorepo; key registry packages below):

- **`@sentinel/core`** — the audit engine. Pure, dependency-light, deterministic.
- **`@git-agentic/sentinel-core`** — the audit engine. Pure, dependency-light, deterministic.
Tarball extraction, the rules, scoring, the data model, and the LLM adapter
interface. No HTTP, no Express — so it is trivially unit-testable and reusable
(CLI, proxy, CI all import it).
- **`@sentinel/proxy`** — Express server implementing the npm registry HTTP API
- **`@git-agentic/sentinel-proxy`** — Express server implementing the npm registry HTTP API
surface we need (`GET /:pkg`, `GET /:pkg/-/:tarball`). Pluggable upstream
(`NpmUpstream` for the real registry, `LocalFixtureUpstream` for hermetic tests).
Owns the verdict cache + audit store and serves the dashboard.
- **`@sentinel/cli`** — `sentinel audit <pkg>` (one-shot) and `sentinel install …`
- **`@git-agentic/sentinel-cli`** — `sentinel audit <pkg>` (one-shot) and `sentinel install …`
(sets `registry` to the proxy and runs npm, showing the pre-install verdict).
- **`@sentinel/steward`** — authenticated DNS-claim issuance, renewal/freeze,
- **`@git-agentic/sentinel-steward`** — authenticated DNS-claim issuance, renewal/freeze,
timelocked ownership changes, and signed offline claim-corpus releases. It is
operationally separate from the proxy's offline resolution path.
- **dashboard** — a single self-contained HTML page served by the proxy at `/`.
Expand Down Expand Up @@ -182,7 +182,7 @@ the policy hash. Frozen/disputed claims remain native for reads but reject write
trusted-publisher enrollments require a matching offline-verified Sigstore SLSA
identity. Stored native versions snapshot the claim namespace, domain, and
claimant key at publication, so an ownership change cannot re-attribute history.
`@sentinel/steward` performs exact-apex DNS challenges, derives grandfather tiers
`@git-agentic/sentinel-steward` performs exact-apex DNS challenges, derives grandfather tiers
from steward-fetched upstream evidence, verifies claimant-key transfer
signatures, applies renewal/domain-change freezes, and atomically publishes
versioned directories after 30-day announced changes (ADR-0046). Mandatory
Expand Down Expand Up @@ -241,7 +241,7 @@ never stored.

### 3.6 Sandbox enforcement (Phases 3–5, ADR-0011/0016/0017/0018)

`@sentinel/sandbox` turns an *approved* capability set into *enforced* runtime
`@git-agentic/sentinel-sandbox` turns an *approved* capability set into *enforced* runtime
least-privilege on macOS and Linux: `generateProfile(approved, {homeDir})` emits an allow-default +
deny-sensitive Seatbelt (SBPL) profile, each deny relaxed by an approved capability; the
`SeatbeltSandbox` runs each lifecycle script under it via `sandbox-exec` (failing closed
Expand Down Expand Up @@ -275,7 +275,12 @@ exists AND `landlock-exec --check` (an ABI probe) exits 0, cached per process;
any negative falls back to the Phase 29 advisory floor with a one-time notice,
so a Landlock-less or no-`cc` host never regresses. `computeDenySet` gains a
`linux-landlock` `execFloorMode` and `classifyViolation` confirms a
floor-outside exec denial as `exec-floor-deny`. The Phase 29 `/dev/null`
floor-outside exec denial as `exec-floor-deny`. The *published*
`@git-agentic/sentinel-sandbox` package ships the helper as source only
(`native/landlock-exec.c` + `scripts/build-native.mjs`) — never a prebuilt
binary and never a `postinstall` compile; a fresh npm install runs this same
advisory fallback until the operator explicitly compiles the helper
(ADR-0052). The Phase 29 `/dev/null`
carve-out is unchanged and still applies (Landlock is allow-list-only and can't
deny a literal under an allowed directory) (ADR-0044). `native` (dlopen/WASM)
remains formally advisory-only on both platforms — no path-level primitive
Expand Down Expand Up @@ -353,7 +358,7 @@ by sniffing for `__metadata:`), and `pnpm-lock.yaml` (YAML across lockfile versi
into the same deduped, sorted `{name, version, integrity?}` `Coordinate[]` regardless of
format (skipping the root entry and `link:`/`file:` deps). Berry checksums are not
SRI-shaped, so berry-parsed coordinates carry no `integrity`. The `yaml` package
(`^2.9.0`) backing the pnpm/berry parsers is a dependency of `@sentinel/core` only. The CLI
(`^2.9.0`) backing the pnpm/berry parsers is a dependency of `@git-agentic/sentinel-core` only. The CLI
POSTs the coordinates (plus an optional `failOnError` flag) to `POST /-/audit-tree`. The
proxy fans out with bounded concurrency over the same integrity-cached `auditVersion()`
path used by the tarball route, then rolls the per-package verdicts into a worst-case-wins
Expand Down Expand Up @@ -757,7 +762,7 @@ deferred.
Phases 1–16 gate installs and lockfiles, but only when a human or CI job
already knows to run `sentinel audit-tree` against a proxy someone started.
Phase 17 adds a self-contained on-ramp into GitHub PRs: a new
**`@sentinel/action`** workspace (`packages/action`, bin `sentinel-ci`) that
**`@git-agentic/sentinel-action`** workspace (`packages/action`, bin `sentinel-ci`) that
needs nothing already running.

- **`runCi(opts)`** (`packages/action/src/run.ts`) self-boots
Expand Down Expand Up @@ -792,9 +797,9 @@ needs nothing already running.
marker instead of always appending a new one.
`.github/workflows/sentinel-example.yml` shows minimal usage.
- **The proxy entrypoint-guard root fix.** `packages/proxy/src/index.ts`'s
`main()` is now guarded the same way `@sentinel/mcp`'s bin already is
`main()` is now guarded the same way `@git-agentic/sentinel-mcp`'s bin already is
(`isEntrypoint()` comparing `import.meta.url` to the resolved
`process.argv[1]`) — importing `@sentinel/proxy` for its exports no
`process.argv[1]`) — importing `@git-agentic/sentinel-proxy` for its exports no
longer boots a listening server as a side effect. This is what makes
`runCi`'s self-boot import-safe.

Expand Down Expand Up @@ -1030,7 +1035,7 @@ releases, deferring version-range CVE matching. Phase 22 closes that gap:
SCA exposure across `audit-tree`'s whole dependency graph.
- A `known-vulnerability` entry in `REMEDIATIONS` (§3.17/ADR-0031) plus a
`vulnerability` `CATEGORY_FALLBACK` entry.
- Adds `semver` (^7.x) as a `@sentinel/core` runtime dependency — the first
- Adds `semver` (^7.x) as a `@git-agentic/sentinel-core` runtime dependency — the first
real semver-range parser in the corpus/rule family.

Faithful severity is a deliberate gating stance (diverges from `npm audit`'s
Expand Down Expand Up @@ -1337,7 +1342,7 @@ which explicitly extends [ADR-0041](./docs/adr/0041-review-hardening.md).

---

## 4. The audit engine (`@sentinel/core`)
## 4. The audit engine (`@git-agentic/sentinel-core`)

Deterministic heuristic core + a pluggable LLM adapter. The score is produced
**entirely by the heuristic rules** so it is reproducible and testable; the LLM
Expand Down Expand Up @@ -1590,8 +1595,8 @@ Five integration modes, all non-invasive:
see; a `ProxyClient` failure (unreachable proxy, non-OK response) throws
`ProxyError` rather than ever fabricating a verdict, and the MCP layer
performs zero scoring of its own (invariant #1 untouched). `parseLockfile`
(used by `sentinel_audit_tree`) moved from `@sentinel/cli` to
`@sentinel/core` this phase so both packages can share it without `mcp`
(used by `sentinel_audit_tree`) moved from `@git-agentic/sentinel-cli` to
`@git-agentic/sentinel-core` this phase so both packages can share it without `mcp`
tripping `cli`'s own entrypoint guard.

The cleanest hook is registry redirection (`.npmrc` `registry=` or `--registry`)
Expand Down
Loading
Loading