Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
deb617c
fix(audit): hash persisted metadata representation
jusso-dev Jul 27, 2026
f013560
fix: invoke agents from direct messages
jusso-dev Jul 27, 2026
fb8999e
fix(research): surface terminal run status
jusso-dev Jul 27, 2026
9710db2
feat(maintenance): add synthetic cleanup manifest guard
jusso-dev Jul 27, 2026
161b0b7
fix(agents): close DM delivery and gateway gaps
jusso-dev Jul 27, 2026
f738614
feat(audit): add immutable integrity verification report
jusso-dev Jul 27, 2026
aec5adb
fix(audit): reject legacy scope and sequence drift
jusso-dev Jul 27, 2026
cfac706
feat: archive synthetic artifact lists
jusso-dev Jul 27, 2026
7ea75b4
fix(seed): preserve declared direct rooms
jusso-dev Jul 27, 2026
92b3096
fix(seed): isolate demo identifiers
jusso-dev Jul 27, 2026
6191824
feat(maintenance): govern synthetic cleanup end to end
jusso-dev Jul 27, 2026
a86759c
ci: harden immutable homelab release handoff
jusso-dev Jul 27, 2026
9e5074d
fix(release): validate immutable image tags
jusso-dev Jul 27, 2026
c0f1d6e
test(release): verify immutable homelab installer
jusso-dev Jul 27, 2026
482958b
ci(release): attest amd64 image evidence
jusso-dev Jul 27, 2026
32611e8
fix(release): protect generated homelab secrets
jusso-dev Jul 27, 2026
800b218
fix(readiness): report serving dependency health
jusso-dev Jul 27, 2026
eb37079
fix(release): preserve homelab upgrade settings
jusso-dev Jul 27, 2026
f760318
ci(release): gate normalized amd64 publication
jusso-dev Jul 27, 2026
b2f3b28
fix(release): bind attestations to amd64 manifest
jusso-dev Jul 27, 2026
bfc790a
ci(release): promote only verified secure digests
jusso-dev Jul 27, 2026
97503ae
ci(release): protect immutable image promotion
jusso-dev Jul 27, 2026
752eda7
fix(release): recognize missing buildx tags
jusso-dev Jul 27, 2026
11c3823
fix(release): publish immutable tags only
jusso-dev Jul 27, 2026
8826639
feat: add governed Slack agent harness
jusso-dev Jul 27, 2026
67ca156
feat: polish Slack agent harness lifecycle
jusso-dev Jul 27, 2026
8cfb1de
test: cover governed Slack harness flow
jusso-dev Jul 27, 2026
27a0860
fix(slack): harden governed ingress boundaries
jusso-dev Jul 27, 2026
11cc322
feat(slack): complete governed admin settings
jusso-dev Jul 27, 2026
6a6e176
fix(slack): require explicit invocation events
jusso-dev Jul 27, 2026
3862c2c
feat(harness): add CLI and MCP adapters
jusso-dev Jul 27, 2026
cf1d7d4
docs(harness): name MCP tools precisely
jusso-dev Jul 27, 2026
91a1a8a
feat(slack): secure governed result links
jusso-dev Jul 27, 2026
18d0915
feat(slack): harden runtime transports
jusso-dev Jul 27, 2026
0ac2d61
test(slack): add hermetic Web API boundary
jusso-dev Jul 27, 2026
17c089f
test(slack): prove hermetic HTTP lifecycle
jusso-dev Jul 27, 2026
9aace06
fix(slack): improve admin accessibility
jusso-dev Jul 27, 2026
c2e6b2b
test(slack): cover admin accessibility
jusso-dev Jul 27, 2026
6fbbc25
fix(slack): preserve governed runtime boundaries
jusso-dev Jul 27, 2026
099c1f6
feat(agents): ground Slack answers in live connectors
jusso-dev Jul 27, 2026
f51e9f2
fix(homelab): wire real connectors and Slack
jusso-dev Jul 27, 2026
7960ead
fix(release): persist requested homelab origin
jusso-dev Jul 27, 2026
7a3bfbd
fix(slack): harden OAuth state signing
jusso-dev Jul 27, 2026
86f6f4f
feat(homelab): bootstrap real governed connectors
jusso-dev Jul 27, 2026
f3481ff
feat(unifi): ground agents in traffic counters
jusso-dev Jul 27, 2026
cb3ae0e
fix(homelab): rotate blank generated secrets
jusso-dev Jul 27, 2026
5ea5a21
fix(security): deduplicate image secret scanning
jusso-dev Jul 27, 2026
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ SENTINEL_CLIENT_ID=
SENTINEL_CLIENT_SECRET=
SENTINEL_WORKSPACE_ID=
MUSTER_MOCK_INTEGRATIONS=true
MUSTER_AGENT_GATEWAY_TOKEN=replace-with-at-least-32-random-bytes
# Optional comma-separated HTTPS origins for additional Alfie research feeds.
MUSTER_RESEARCH_ALLOWED_FEED_ORIGINS=
182 changes: 153 additions & 29 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ jobs:
mc version enable ci/muster-evidence &&
mc anonymous set none ci/muster-evidence'
- run: pnpm install --frozen-lockfile
- run: pnpm test:release-homelab
- run: pnpm test:release-image
- name: Build database dependencies
run: pnpm exec turbo build --filter=@muster/database
- run: pnpm db:migrate
Expand Down Expand Up @@ -103,77 +105,199 @@ jobs:
if-no-files-found: ignore
retention-days: 14

release-security:
permissions:
contents: read
security-events: write
uses: ./.github/workflows/security.yml

container:
needs: quality
runs-on: ubuntu-24.04
outputs:
image_ref: ${{ steps.image.outputs.ref }}
image_digest: ${{ steps.build.outputs.digest }}
permissions:
contents: read
packages: write
attestations: write
id-token: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Normalize image reference
id: image
shell: bash
run: |
image_ref="${REGISTRY}/${GITHUB_REPOSITORY,,}"
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
build_ref="${image_ref}:staging-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
else
build_ref="${image_ref}:verify-${GITHUB_SHA}"
fi
{
printf 'ref=%s\n' "$image_ref"
printf 'build_ref=%s\n' "$build_ref"
} >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: github.event_name == 'push'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate image tags and OCI labels
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=tag
type=sha
labels: |
org.opencontainers.image.title=Muster
org.opencontainers.image.description=Shared workspace for human and agent-driven security operations
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
id: build
with:
context: .
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: ${{ github.event_name == 'push' && 'mode=max' || 'false' }}
sbom: ${{ github.event_name == 'push' }}
tags: ${{ steps.image.outputs.build_ref }}
labels: |
org.opencontainers.image.title=Muster
org.opencontainers.image.description=Shared workspace for human and agent-driven security operations
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
provenance: mode=max
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify container starts
- name: Scan built image with Trivy
run: |
docker run --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:0.67.2 image \
--exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL \
"${{ steps.image.outputs.build_ref }}"
- name: Generate release SBOM
if: github.event_name == 'push'
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with:
image: ${{ steps.image.outputs.ref }}@${{ steps.build.outputs.digest }}
format: cyclonedx-json
output-file: muster-sbom.cdx.json
upload-artifact: false
- name: Generate pull request SBOM
if: github.event_name == 'pull_request'
shell: bash
uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with:
image: ${{ steps.image.outputs.build_ref }}
format: cyclonedx-json
output-file: muster-sbom.cdx.json
upload-artifact: false
- name: Verify staged OCI application platform
if: github.event_name == 'push'
run: |
docker buildx imagetools inspect \
"${{ steps.image.outputs.ref }}@${{ steps.build.outputs.digest }}" --raw |
./scripts/verify-image-platform.sh
- name: Record immutable image evidence and checksums
if: github.event_name == 'push'
run: |
image_ref="${REGISTRY}/${IMAGE_NAME,,}:sha-${GITHUB_SHA::7}"
docker image inspect "$image_ref"
- name: Attest published image
printf '%s\n' \
"${{ steps.image.outputs.ref }}@${{ steps.build.outputs.digest }}" > muster-image.txt
sha256sum muster-sbom.cdx.json muster-image.txt > SHA256SUMS
- name: Upload release evidence
if: github.event_name == 'push'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: muster-release-evidence
path: |
muster-sbom.cdx.json
muster-image.txt
SHA256SUMS
- name: Verify container starts
if: github.event_name == 'pull_request'
run: docker image inspect "${{ steps.image.outputs.build_ref }}"
- name: Attest published image provenance
if: github.event_name == 'push'
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-name: ${{ steps.image.outputs.ref }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
- name: Attest published image SBOM
if: github.event_name == 'push'
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-name: ${{ steps.image.outputs.ref }}
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: muster-sbom.cdx.json
push-to-registry: true
- name: Verify anonymous public pull
- name: Verify anonymous staging pull
if: github.event_name == 'push'
shell: bash
run: |
public_image="${REGISTRY}/${IMAGE_NAME,,}:sha-${GITHUB_SHA::7}"
docker logout "$REGISTRY"
public_image="${{ steps.image.outputs.build_ref }}"
docker logout "$REGISTRY" || true
docker pull "$public_image"
docker image inspect "$public_image" \
--format '{{.Os}}/{{.Architecture}}' | grep -x 'linux/amd64'
docker buildx imagetools inspect "$public_image" --raw |
./scripts/verify-image-platform.sh

promote:
if: github.event_name == 'push'
needs: [container, release-security]
runs-on: ubuntu-24.04
concurrency:
group: muster-release-tags-${{ github.repository }}
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_REF: ${{ needs.container.outputs.image_ref }}
IMAGE_DIGEST: ${{ needs.container.outputs.image_digest }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Generate release tags
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.IMAGE_REF }}
tags: |
type=ref,event=tag
type=sha,format=long
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Promote verified digest to release tags
env:
RELEASE_TAGS: ${{ steps.meta.outputs.tags }}
run: |
promotion_policy() {
case "${1##*:}" in
"sha-${GITHUB_SHA}" | v*) printf 'immutable\n' ;;
*)
printf 'Unexpected release tag: %s\n' "$1" >&2
return 1
;;
esac
}
while IFS= read -r tag; do
[[ -n "$tag" ]] || continue
policy="$(promotion_policy "$tag")"
./scripts/promote-image-tag.sh \
"$tag" "${IMAGE_REF}@${IMAGE_DIGEST}" "$policy" check
done <<< "$RELEASE_TAGS"
while IFS= read -r tag; do
[[ -n "$tag" ]] || continue
policy="$(promotion_policy "$tag")"
./scripts/promote-image-tag.sh \
"$tag" "${IMAGE_REF}@${IMAGE_DIGEST}" "$policy" apply
done <<< "$RELEASE_TAGS"
- name: Publication summary
if: github.event_name == 'push'
run: |
{
echo "### Published container"
echo "\`${REGISTRY}/${IMAGE_NAME}\`"
echo "\`${IMAGE_REF}@${IMAGE_DIGEST}\`"
echo "The workflow verified an anonymous pull after publication. GHCR visibility is configured once at the package level and retained by subsequent releases."
} >> "$GITHUB_STEP_SUMMARY"
5 changes: 2 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
name: Security and supply chain

on:
push:
branches: [main]
pull_request:
workflow_call:
schedule:
- cron: "17 3 * * 1"

Expand Down Expand Up @@ -66,6 +64,7 @@ jobs:
output: trivy.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
scanners: vuln
exit-code: "1"
- uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0
with:
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,10 @@ that will use the service. When using HTTPS behind a reverse proxy, set
`AUTH_SECURE_COOKIES=true`; do not add broad wildcard origins.

The installer creates `.env.homelab` with mode `600`. Keep it out of source
control. The generated topology still uses synthetic connector endpoints until
you deliberately configure governed real connectors.
control. Its generated `MUSTER_AGENT_GATEWAY_TOKEN` authenticates internal
web/worker calls to the unexposed agent gateway; do not reuse or publish it.
The generated topology still uses synthetic connector endpoints until you
deliberately configure governed real connectors.

### Codex subscription authentication

Expand Down
1 change: 1 addition & 0 deletions apps/agent-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@muster/config": "workspace:*",
"@muster/contracts": "workspace:*",
"@muster/database": "workspace:*",
"@muster/integrations": "workspace:*",
"@openai/codex": "0.145.0",
"@openai/codex-sdk": "0.145.0",
"drizzle-orm": "0.45.2",
Expand Down
44 changes: 42 additions & 2 deletions apps/agent-gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import { DurableAgentRuntime } from "./runtime.ts";
import {
isGatewayRequestAuthorised,
parseGatewayOrganisationId,
} from "./service-auth.ts";

const AgentRunRequestSchema = AgentInvestigationJobSchema.extend({
humanRequest: z.string().trim().min(1).max(4_000).optional(),
Expand All @@ -24,6 +28,10 @@ const executionRuntime =
process.env.MUSTER_AGENT_RUNTIME === "mock" ? "mock" : "codex";
const codexHome = process.env.CODEX_HOME ?? "/var/lib/muster/codex";
const globalKillSwitch = process.env.AGENT_KILL_SWITCH === "true";
const gatewayToken = z
.string()
.min(32)
.parse(process.env.MUSTER_AGENT_GATEWAY_TOKEN);
const runtime = new DurableAgentRuntime({
executionRuntime,
codexHome,
Expand All @@ -47,6 +55,12 @@ async function body(request: IncomingMessage): Promise<unknown> {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}

function requestOrganisationId(request: IncomingMessage) {
return parseGatewayOrganisationId(
request.headers["x-muster-organisation-id"],
);
}

async function queueDirectRun(
input: z.infer<typeof AgentRunRequestSchema>,
idempotencyKey: string,
Expand Down Expand Up @@ -170,12 +184,26 @@ const server = createServer(async (incoming, response) => {
return;
}

if (
!isGatewayRequestAuthorised(incoming.headers.authorization, gatewayToken)
) {
response.writeHead(401);
response.end(JSON.stringify({ error: "Unauthorised" }));
return;
}

const runMatch =
incoming.method === "GET"
? url.pathname.match(/^\/v1\/runs\/([^/]+)$/)
: null;
if (runMatch?.[1]) {
const run = await runtime.read(runMatch[1]);
const organisationId = requestOrganisationId(incoming);
if (!organisationId) {
response.writeHead(400);
response.end(JSON.stringify({ error: "Organisation header required" }));
return;
}
const run = await runtime.read(runMatch[1], organisationId);
response.writeHead(run ? 200 : 404);
response.end(JSON.stringify(run ?? { error: "Run not found" }));
return;
Expand Down Expand Up @@ -234,6 +262,12 @@ const server = createServer(async (incoming, response) => {
);
return;
}
const organisationId = requestOrganisationId(incoming);
if (!organisationId || organisationId !== parsed.data.organisationId) {
response.writeHead(403);
response.end(JSON.stringify({ error: "Organisation mismatch" }));
return;
}
const idempotencyKey =
incoming.headers["idempotency-key"]?.toString().trim() ||
`api:${parsed.data.traceId}`;
Expand Down Expand Up @@ -269,7 +303,13 @@ const server = createServer(async (incoming, response) => {
? url.pathname.match(/^\/v1\/runs\/([^/]+)\/cancel$/)
: null;
if (cancelMatch?.[1]) {
const cancelled = await runtime.cancel(cancelMatch[1]);
const organisationId = requestOrganisationId(incoming);
if (!organisationId) {
response.writeHead(400);
response.end(JSON.stringify({ error: "Organisation header required" }));
return;
}
const cancelled = await runtime.cancel(cancelMatch[1], organisationId);
response.writeHead(cancelled ? 202 : 404);
response.end(
JSON.stringify({
Expand Down
Loading
Loading