voidforge-build 23.19.0 → 23.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.claude/agents/celebrimbor-forge-artist.md +1 -0
- package/dist/.claude/agents/ducem-token-economics.md +1 -0
- package/dist/.claude/agents/galadriel-frontend.md +1 -0
- package/dist/.claude/agents/romanoff-integrations.md +4 -0
- package/dist/.claude/agents/silver-surfer-herald.md +19 -4
- package/dist/.claude/commands/architect.md +4 -3
- package/dist/.claude/commands/assemble.md +12 -0
- package/dist/.claude/commands/assess.md +1 -0
- package/dist/.claude/commands/build.md +8 -0
- package/dist/.claude/commands/contextmeter.md +56 -0
- package/dist/.claude/commands/debrief.md +10 -0
- package/dist/.claude/commands/engage.md +5 -0
- package/dist/.claude/commands/git.md +19 -3
- package/dist/.claude/commands/imagine.md +1 -1
- package/dist/.claude/commands/seal.md +81 -0
- package/dist/.claude/commands/ux.md +13 -0
- package/dist/.claude/workflows/gauntlet.workflow.js +13 -1
- package/dist/CHANGELOG.md +63 -0
- package/dist/CLAUDE.md +10 -1
- package/dist/HOLOCRON.md +16 -2
- package/dist/VERSION.md +3 -1
- package/dist/docs/methods/AI_INTELLIGENCE.md +3 -0
- package/dist/docs/methods/ASSEMBLER.md +12 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +15 -0
- package/dist/docs/methods/CAMPAIGN.md +11 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +66 -0
- package/dist/docs/methods/FIELD_MEDIC.md +1 -0
- package/dist/docs/methods/FORGE_ARTIST.md +3 -4
- package/dist/docs/methods/GAUNTLET.md +6 -0
- package/dist/docs/methods/MUSTER.md +2 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +18 -0
- package/dist/docs/methods/QA_ENGINEER.md +21 -1
- package/dist/docs/methods/RELEASE_MANAGER.md +38 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +11 -1
- package/dist/docs/methods/SUB_AGENTS.md +33 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +15 -0
- package/dist/docs/methods/TESTING.md +2 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +2 -2
- package/dist/docs/methods/WORKFLOWS.md +14 -0
- package/dist/docs/patterns/ai-prompt-safety.ts +85 -0
- package/dist/docs/patterns/data-pipeline.ts +59 -1
- package/dist/docs/patterns/egress-sandbox.sh +43 -0
- package/dist/docs/patterns/exclusion-set-invariant.md +62 -0
- package/dist/docs/patterns/multi-tenant-property-test.ts +64 -0
- package/dist/docs/patterns/nginx-vhost.conf +156 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +21 -0
- package/dist/docs/patterns/post-deploy-probe.sh +115 -0
- package/dist/docs/patterns/rls-test-fixture.py +140 -0
- package/dist/docs/patterns/structural-sql-sentinel.py +134 -0
- package/dist/scripts/statusline/README.md +38 -0
- package/dist/scripts/statusline/context-awareness-hook.sh +53 -0
- package/dist/scripts/statusline/settings-snippet.json +17 -0
- package/dist/scripts/statusline/voidforge-statusline.sh +91 -0
- package/dist/scripts/voidforge.js +69 -6
- package/dist/wizard/lib/claude-md-strategy.d.ts +87 -0
- package/dist/wizard/lib/claude-md-strategy.js +198 -0
- package/dist/wizard/lib/marker.d.ts +48 -1
- package/dist/wizard/lib/marker.js +58 -2
- package/dist/wizard/lib/patterns/oauth-token-lifecycle.d.ts +14 -0
- package/dist/wizard/lib/patterns/oauth-token-lifecycle.js +21 -0
- package/dist/wizard/lib/project-init.js +59 -0
- package/dist/wizard/lib/updater.d.ts +19 -0
- package/dist/wizard/lib/updater.js +84 -33
- package/package.json +2 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# nginx vhost — Cloudflare-Flexible-compatible reverse proxy with ACME passthrough
|
|
2
|
+
#
|
|
3
|
+
# Reference implementation for a per-tenant origin vhost that sits behind
|
|
4
|
+
# Cloudflare in *Flexible* SSL mode (browser<->Cloudflare is HTTPS, but
|
|
5
|
+
# Cloudflare<->origin is plain HTTP on :80). Use this when the origin app
|
|
6
|
+
# does NOT terminate TLS itself and Cloudflare fronts the zone.
|
|
7
|
+
#
|
|
8
|
+
# Evidence: field report #344 F2 (origin 301 HTTP->HTTPS redirect loops on
|
|
9
|
+
# Cloudflare Flexible zones) and #344 F4a (missing/origin-level security header
|
|
10
|
+
# stack + per-tenant log isolation).
|
|
11
|
+
#
|
|
12
|
+
# When to use it:
|
|
13
|
+
# - Cloudflare zone SSL mode = Flexible, origin speaks HTTP only.
|
|
14
|
+
# - You want the security header stack applied at the origin so it survives
|
|
15
|
+
# even if a Cloudflare Transform/Response-Header rule is removed.
|
|
16
|
+
# - You need Let's Encrypt http-01 (ACME) to keep working through the proxy.
|
|
17
|
+
#
|
|
18
|
+
# When NOT to use it (use a TLS-terminating vhost instead):
|
|
19
|
+
# - Cloudflare SSL mode = Full or Full (strict): the origin must serve HTTPS
|
|
20
|
+
# and you SHOULD redirect HTTP->HTTPS. Adding the 301 below would NOT loop
|
|
21
|
+
# in that mode. This template deliberately omits the redirect for Flexible.
|
|
22
|
+
#
|
|
23
|
+
# Install:
|
|
24
|
+
# - Copy to /etc/nginx/sites-available/<tenant>.conf, edit the @@PLACEHOLDERS@@,
|
|
25
|
+
# symlink into sites-enabled/, then `nginx -t && systemctl reload nginx`.
|
|
26
|
+
# - Replace @@SERVER_NAME@@, @@UPSTREAM@@, @@TENANT@@ before loading.
|
|
27
|
+
#
|
|
28
|
+
# Placeholders:
|
|
29
|
+
# @@SERVER_NAME@@ -> e.g. app.example.com
|
|
30
|
+
# @@UPSTREAM@@ -> origin app host:port, e.g. 127.0.0.1:3000
|
|
31
|
+
# @@TENANT@@ -> short slug used for log filenames, e.g. acme-corp
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Rate-limit zone declaration.
|
|
35
|
+
#
|
|
36
|
+
# IMPORTANT (field report #344 F4a): limit_req_zone is an http{}-context-only
|
|
37
|
+
# directive. It MUST live in the top-level http{} block (e.g. nginx.conf or a
|
|
38
|
+
# file in conf.d/ that is included at http scope) — it is NOT valid inside a
|
|
39
|
+
# server{} or location{} block and `nginx -t` will reject it there with
|
|
40
|
+
# "limit_req_zone directive is not allowed here".
|
|
41
|
+
#
|
|
42
|
+
# Declare the zone ONCE at http{} scope, then *apply* it per-location with
|
|
43
|
+
# `limit_req` (which IS valid in server{}/location{}). The line below is shown
|
|
44
|
+
# here for reference only — move it to your http{} context:
|
|
45
|
+
#
|
|
46
|
+
# limit_req_zone $binary_remote_addr zone=@@TENANT@@_perip:10m rate=20r/s;
|
|
47
|
+
#
|
|
48
|
+
# The WebSocket upgrade map below is ALSO http{}-context-only — declare it once
|
|
49
|
+
# at http{} scope alongside the rate-limit zone, not inside server{}:
|
|
50
|
+
#
|
|
51
|
+
# map $http_upgrade $connection_upgrade {
|
|
52
|
+
# default upgrade;
|
|
53
|
+
# '' close; # plain HTTP keep-alive: send Connection: close-able
|
|
54
|
+
# }
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
upstream @@TENANT@@_origin {
|
|
58
|
+
# Origin application. keepalive reduces TCP churn on the proxy hop.
|
|
59
|
+
server @@UPSTREAM@@;
|
|
60
|
+
keepalive 32;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
server {
|
|
64
|
+
listen 80;
|
|
65
|
+
listen [::]:80;
|
|
66
|
+
server_name @@SERVER_NAME@@;
|
|
67
|
+
|
|
68
|
+
# --- Per-tenant access/error logs (field report #344 F4a) -------------
|
|
69
|
+
# Isolated per tenant so one tenant's traffic/errors never contaminate
|
|
70
|
+
# another's audit trail. Ensure /var/log/nginx exists and is writable.
|
|
71
|
+
access_log /var/log/nginx/@@TENANT@@.access.log combined;
|
|
72
|
+
error_log /var/log/nginx/@@TENANT@@.error.log warn;
|
|
73
|
+
|
|
74
|
+
# --- ACME http-01 passthrough (field report #344 F4a) -----------------
|
|
75
|
+
# Let's Encrypt validates by fetching /.well-known/acme-challenge/<token>
|
|
76
|
+
# over plain HTTP. This location MUST be reachable on :80 and MUST NOT be
|
|
77
|
+
# redirected or proxied to the app, or certificate issuance/renewal fails.
|
|
78
|
+
# Point root at the webroot your ACME client writes challenges into.
|
|
79
|
+
location ^~ /.well-known/acme-challenge/ {
|
|
80
|
+
default_type "text/plain";
|
|
81
|
+
root /var/www/acme;
|
|
82
|
+
# No proxy here — serve the challenge token straight from the webroot.
|
|
83
|
+
# NOTE: nginx add_header inheritance is replace-not-merge, but a block
|
|
84
|
+
# with zero add_header of its own inherits the parent's. The security
|
|
85
|
+
# headers declared at server{} scope below are thus also emitted here;
|
|
86
|
+
# that is harmless for a bare token response. If you must strip them on
|
|
87
|
+
# this path, redeclare the headers you want (or none) inside this block.
|
|
88
|
+
try_files $uri =404;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# --- Security header stack (field report #344 F4a) --------------------
|
|
92
|
+
# Applied at the origin so the posture survives even if a Cloudflare
|
|
93
|
+
# response-header rule is later removed. `always` ensures the headers are
|
|
94
|
+
# emitted on error responses (4xx/5xx) too, not just 2xx/3xx.
|
|
95
|
+
#
|
|
96
|
+
# HSTS-aware: behind Cloudflare Flexible the browser<->edge leg is HTTPS,
|
|
97
|
+
# so advertising HSTS is correct for the public hostname. Start with a
|
|
98
|
+
# short max-age while validating, then raise to 1y + preload once certain
|
|
99
|
+
# the apex and every subdomain are HTTPS-only at the edge.
|
|
100
|
+
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
101
|
+
add_header X-Content-Type-Options "nosniff" always;
|
|
102
|
+
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
103
|
+
# frame-ancestors is the modern replacement for X-Frame-Options; both are
|
|
104
|
+
# sent for backward compatibility with older user agents.
|
|
105
|
+
add_header Content-Security-Policy "frame-ancestors 'self'" always;
|
|
106
|
+
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
107
|
+
|
|
108
|
+
# --- Reverse proxy to the origin app ----------------------------------
|
|
109
|
+
# NOTE (field report #344 F2): there is deliberately NO
|
|
110
|
+
# `return 301 https://$host$request_uri;`
|
|
111
|
+
# and NO `if ($http_x_forwarded_proto != "https") { return 301 ...; }`
|
|
112
|
+
# here. On a Cloudflare *Flexible* zone the edge talks HTTP to the origin,
|
|
113
|
+
# so any origin-level HTTP->HTTPS redirect produces an infinite redirect
|
|
114
|
+
# loop (ERR_TOO_MANY_REDIRECTS). Let Cloudflare handle the HTTPS upgrade
|
|
115
|
+
# at the edge. If you switch the zone to Full/Full-strict, terminate TLS
|
|
116
|
+
# at the origin and add the redirect in a separate :80 server block.
|
|
117
|
+
location / {
|
|
118
|
+
# Apply the http{}-scoped rate-limit zone here (this is the valid
|
|
119
|
+
# context for limit_req; the zone itself is declared at http{} scope
|
|
120
|
+
# per the note at the top of this file).
|
|
121
|
+
limit_req zone=@@TENANT@@_perip burst=40 nodelay;
|
|
122
|
+
|
|
123
|
+
proxy_pass http://@@TENANT@@_origin;
|
|
124
|
+
proxy_http_version 1.1;
|
|
125
|
+
|
|
126
|
+
# Preserve client + protocol context for the app. X-Forwarded-Proto is
|
|
127
|
+
# taken from Cloudflare's CF-Visitor / the edge; default to https since
|
|
128
|
+
# the public-facing leg is HTTPS even though this hop is HTTP.
|
|
129
|
+
proxy_set_header Host $host;
|
|
130
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
131
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
132
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
133
|
+
proxy_set_header X-Forwarded-Host $host;
|
|
134
|
+
|
|
135
|
+
# WebSocket / upgrade support. $connection_upgrade is the http{}-scoped
|
|
136
|
+
# map declared at the top of this file: it sends "upgrade" for WS and
|
|
137
|
+
# "close" for plain HTTP. This single directive replaces the conflicting
|
|
138
|
+
# `Connection "upgrade"` + `Connection ""` pair and lets keepalive to
|
|
139
|
+
# the upstream coexist with WebSocket upgrades.
|
|
140
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
141
|
+
proxy_set_header Connection $connection_upgrade;
|
|
142
|
+
|
|
143
|
+
proxy_connect_timeout 5s;
|
|
144
|
+
proxy_send_timeout 60s;
|
|
145
|
+
proxy_read_timeout 60s;
|
|
146
|
+
|
|
147
|
+
proxy_buffering on;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Lightweight health endpoint that bypasses the app, for the edge/monitor.
|
|
151
|
+
location = /healthz {
|
|
152
|
+
access_log off;
|
|
153
|
+
default_type "application/json";
|
|
154
|
+
return 200 '{"status":"ok"}';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
* - Failure escalation: retry 3x → pause platform → alert → requires_reauth
|
|
9
9
|
* - Token stored as encrypted blob in vault, keyed by platform name
|
|
10
10
|
* - Session token (daemon) rotates every 24 hours (§9.19.15)
|
|
11
|
+
* - VERIFY EXPIRY + REFRESH-GRANT BEHAVIOR AGAINST THE PROVIDER'S LIVE DOCS AT
|
|
12
|
+
* INTEGRATION TIME. The PLATFORM_CONFIGS TTLs below are STARTING ASSUMPTIONS,
|
|
13
|
+
* not ground truth — providers change them and "no refresh token / never
|
|
14
|
+
* expires" is a common false assumption. Field report #373: a Todoist
|
|
15
|
+
* integration shipped on "tokens don't expire," but the modern API issues
|
|
16
|
+
* ~1h access tokens WITH a refresh token; the code discarded the refresh
|
|
17
|
+
* token + expiry and registered no refresher, so it died ~1h after every
|
|
18
|
+
* connect across four sessions — looking exactly like intermittent
|
|
19
|
+
* revocation. At integration time: (1) read the provider's OAuth docs and
|
|
20
|
+
* quote the verified access-token TTL + whether a refresh_token is issued;
|
|
21
|
+
* (2) if a refresh_token exists, PERSIST it and register a refresher — never
|
|
22
|
+
* discard it; (3) distinguish "expired" from "revoked" via the API's OWN
|
|
23
|
+
* error body, not by inference (an expired token that mimics revocation will
|
|
24
|
+
* send you reauth-hunting instead of refreshing).
|
|
11
25
|
*
|
|
12
26
|
* Agents: Breeze (platform relations), Dockson (vault)
|
|
13
27
|
*
|
|
@@ -50,6 +64,13 @@ interface PlatformTokenConfig {
|
|
|
50
64
|
revokeEndpoint?: string;
|
|
51
65
|
}
|
|
52
66
|
|
|
67
|
+
// ASSUMPTIONS, NOT GROUND TRUTH (field report #373). These TTLs and the
|
|
68
|
+
// "refreshTokenTtlDays: 0 = never expires" entries are starting points. At
|
|
69
|
+
// integration time, VERIFY each value against the provider's current OAuth
|
|
70
|
+
// docs and the live token response (`expires_in`, presence of `refresh_token`)
|
|
71
|
+
// — a provider that "doesn't expire" today may issue ~1h tokens tomorrow, and
|
|
72
|
+
// a missing refresher then surfaces as recurring prod token-death that mimics
|
|
73
|
+
// revocation. Treat any new platform here the same way before shipping.
|
|
53
74
|
const PLATFORM_CONFIGS: PlatformTokenConfig[] = [
|
|
54
75
|
{ platform: 'meta', accessTokenTtlHours: 1440, refreshTokenTtlDays: 0, refreshEndpoint: 'https://graph.facebook.com/v19.0/oauth/access_token' },
|
|
55
76
|
{ platform: 'google', accessTokenTtlHours: 1, refreshTokenTtlDays: 0, refreshEndpoint: 'https://oauth2.googleapis.com/token' },
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Post-Deploy Probe — Assert sensitive paths are NOT publicly served.
|
|
3
|
+
#
|
|
4
|
+
# Reference implementation for .claude/commands/deploy.md Step 4.5.
|
|
5
|
+
# Probes a denylist of paths against a live deploy URL.
|
|
6
|
+
#
|
|
7
|
+
# CONTENT-AWARE, NOT STATUS-ONLY (field report #371). A Single-Page App with a
|
|
8
|
+
# catch-all route returns HTTP 200 for EVERY path — /.env, /.git/config,
|
|
9
|
+
# /id_rsa — by serving index.html. A status-only probe reads those 200s as
|
|
10
|
+
# "EXPOSED" and would trigger a ROLLBACK of a clean deploy (false positive),
|
|
11
|
+
# while a real leak that happens to 200 looks identical to the shell. So we
|
|
12
|
+
# assert on CONTENT and Content-Type, not status alone:
|
|
13
|
+
# - 200 + text/html shell (<!doctype html> / <html) -> PASS (SPA fallback)
|
|
14
|
+
# - 200 + non-HTML body (KEY=VALUE, JSON, PEM, "ref:", binary) -> EXPOSED (real leak)
|
|
15
|
+
# - non-200 -> PASS (not served)
|
|
16
|
+
# A real .env leak is text/plain `KEY=VALUE`; a .git/config is an INI `[core]`
|
|
17
|
+
# block; an id_rsa is a `-----BEGIN ... PRIVATE KEY-----` PEM. None are HTML.
|
|
18
|
+
#
|
|
19
|
+
# Evidence: field reports #305 (32-day credential leak), #303 (methodology
|
|
20
|
+
# exposure), #371 (SPA catch-all status-only false-positive → would-be rollback).
|
|
21
|
+
#
|
|
22
|
+
# Usage:
|
|
23
|
+
# DEPLOY_URL=https://example.com bash docs/patterns/post-deploy-probe.sh
|
|
24
|
+
# DEPLOY_URL=https://example.com DEPLOY_PROBE_EXTRA=$'/admin\n/private.key' bash docs/patterns/post-deploy-probe.sh
|
|
25
|
+
|
|
26
|
+
set -euo pipefail
|
|
27
|
+
|
|
28
|
+
: "${DEPLOY_URL:?DEPLOY_URL is required (e.g. https://example.com)}"
|
|
29
|
+
|
|
30
|
+
# Strip trailing slash for clean URL composition.
|
|
31
|
+
DEPLOY_URL="${DEPLOY_URL%/}"
|
|
32
|
+
|
|
33
|
+
TMP="$(mktemp -t postdeploy-probe.XXXXXX)"
|
|
34
|
+
BODY="$(mktemp -t postdeploy-body.XXXXXX)"
|
|
35
|
+
cleanup() { rm -f "$TMP" "$BODY"; }
|
|
36
|
+
trap cleanup EXIT INT TERM
|
|
37
|
+
|
|
38
|
+
# Fixed denylist — mirrors Step 4.5 in .claude/commands/deploy.md.
|
|
39
|
+
DENYLIST=(
|
|
40
|
+
"/.env"
|
|
41
|
+
"/.env.production"
|
|
42
|
+
"/.env.local"
|
|
43
|
+
"/.git/config"
|
|
44
|
+
"/.git/HEAD"
|
|
45
|
+
"/.claude/agents/silver-surfer-herald.md"
|
|
46
|
+
"/docs/methods/FORGE_KEEPER.md"
|
|
47
|
+
"/HOLOCRON.md"
|
|
48
|
+
"/CHANGELOG.md"
|
|
49
|
+
"/VERSION.md"
|
|
50
|
+
"/package.json"
|
|
51
|
+
"/tsconfig.json"
|
|
52
|
+
"/id_rsa"
|
|
53
|
+
"/.ssh/id_rsa"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Optional extensible denylist (newline-separated).
|
|
57
|
+
if [[ -n "${DEPLOY_PROBE_EXTRA:-}" ]]; then
|
|
58
|
+
while IFS= read -r extra; do
|
|
59
|
+
[[ -n "$extra" ]] && DENYLIST+=("$extra")
|
|
60
|
+
done <<< "$DEPLOY_PROBE_EXTRA"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# Decide whether a fetched path is a REAL leak vs an SPA HTML fallback.
|
|
64
|
+
# Inputs: $1 status, $2 content-type header, body file at $BODY.
|
|
65
|
+
# Echoes "leak" or "ok".
|
|
66
|
+
classify() {
|
|
67
|
+
local status="$1" ctype="$2"
|
|
68
|
+
# Only a 200 can possibly be a leak; anything else is not served.
|
|
69
|
+
[[ "$status" == "200" ]] || { echo "ok"; return; }
|
|
70
|
+
|
|
71
|
+
# Lowercase the content-type for matching.
|
|
72
|
+
local ct; ct="$(printf '%s' "$ctype" | tr '[:upper:]' '[:lower:]')"
|
|
73
|
+
|
|
74
|
+
# An HTML response is the SPA catch-all shell, not the sensitive file. PASS.
|
|
75
|
+
if [[ "$ct" == *"text/html"* ]]; then echo "ok"; return; fi
|
|
76
|
+
# Body-sniff fallback when the server omits/mislabels Content-Type: a leading
|
|
77
|
+
# <!doctype html> or <html is the SPA shell.
|
|
78
|
+
if head -c 256 "$BODY" | tr '[:upper:]' '[:lower:]' | grep -qE '<!doctype html|<html'; then
|
|
79
|
+
echo "ok"; return
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
# 200 + non-HTML body = the real file is being served. EXPOSED.
|
|
83
|
+
echo "leak"
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
hits=0
|
|
87
|
+
checked=0
|
|
88
|
+
|
|
89
|
+
for path in "${DENYLIST[@]}"; do
|
|
90
|
+
checked=$((checked + 1))
|
|
91
|
+
url="${DEPLOY_URL}${path}"
|
|
92
|
+
# Capture status + content-type, and the body (capped) for sniffing.
|
|
93
|
+
read -r status ctype < <(
|
|
94
|
+
curl -s -o "$BODY" --max-time 10 \
|
|
95
|
+
-w '%{http_code} %{content_type}\n' "$url" 2>/dev/null || echo "000 -"
|
|
96
|
+
)
|
|
97
|
+
verdict="$(classify "$status" "$ctype")"
|
|
98
|
+
if [[ "$verdict" == "leak" ]]; then
|
|
99
|
+
hits=$((hits + 1))
|
|
100
|
+
printf 'LEAK %s %-24s -> %s\n' "$status" "$ctype" "$url" | tee -a "$TMP" >&2
|
|
101
|
+
else
|
|
102
|
+
printf 'ok %s %-24s -> %s\n' "$status" "$ctype" "$url"
|
|
103
|
+
fi
|
|
104
|
+
done
|
|
105
|
+
|
|
106
|
+
printf '{"action":"post-deploy-probe","url":"%s","checked":%d,"hits":%d,"mode":"content-aware"}\n' \
|
|
107
|
+
"$DEPLOY_URL" "$checked" "$hits"
|
|
108
|
+
|
|
109
|
+
if (( hits > 0 )); then
|
|
110
|
+
echo "[post-deploy-probe] ${hits} sensitive path(s) served as non-HTML content. Rollback and fix deploy surface." >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
echo "[post-deploy-probe] clean (SPA HTML fallbacks treated as PASS)"
|
|
115
|
+
exit 0
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern: RLS Test Fixture (db_as_app SAVEPOINT)
|
|
3
|
+
|
|
4
|
+
Source: Field report #318 §5. Cara Dune (Union Station, M-05) discovered that
|
|
5
|
+
Testcontainers' default `us_test` user is `SUPERUSER + BYPASSRLS=t`.
|
|
6
|
+
Superusers bypass FORCE RLS at the engine level — the policy doesn't fire.
|
|
7
|
+
Any test using the shared `db` fixture for cross-tenant assertions will
|
|
8
|
+
silently pass even when the policy is broken.
|
|
9
|
+
|
|
10
|
+
Without this pattern, RLS tests that pass in development WILL silently fail
|
|
11
|
+
in production under the runtime non-owner role.
|
|
12
|
+
|
|
13
|
+
Use this pattern in every Python/asyncpg + pytest project with FORCE RLS.
|
|
14
|
+
The same shape ports to SQLAlchemy + sync sessions, psycopg, and Django ORM.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
import pytest_asyncio
|
|
19
|
+
import asyncpg
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from typing import AsyncIterator
|
|
22
|
+
|
|
23
|
+
# ── Fixtures ──────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest_asyncio.fixture
|
|
27
|
+
async def db_as_app(db: asyncpg.Connection) -> AsyncIterator[asyncpg.Connection]:
|
|
28
|
+
"""
|
|
29
|
+
Wrap the standard `db` fixture so RLS-sensitive tests run under the app
|
|
30
|
+
role (BYPASSRLS=f), not the SUPERUSER bootstrap role. Connection state
|
|
31
|
+
is restored on test teardown.
|
|
32
|
+
|
|
33
|
+
Use this fixture for any test that asserts an RLS policy fires. Use the
|
|
34
|
+
standard `db` fixture only for schema setup or admin-only operations.
|
|
35
|
+
|
|
36
|
+
Pairs with a `pg_container_app` fixture (below) that provisions an
|
|
37
|
+
app-level role with `LOGIN NOBYPASSRLS NOSUPERUSER` matching the
|
|
38
|
+
runtime DSN identity.
|
|
39
|
+
"""
|
|
40
|
+
await db.execute("SAVEPOINT rls_test")
|
|
41
|
+
try:
|
|
42
|
+
await db.execute(f"SET LOCAL ROLE {APP_ROLE_NAME}")
|
|
43
|
+
# If the test sets a tenant ContextVar, wire it through:
|
|
44
|
+
# await db.execute("SELECT set_config('app.current_org_id', $1, true)", org_id)
|
|
45
|
+
yield db
|
|
46
|
+
finally:
|
|
47
|
+
await db.execute("ROLLBACK TO SAVEPOINT rls_test")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture(scope="session")
|
|
51
|
+
def app_role_name() -> str:
|
|
52
|
+
return APP_ROLE_NAME
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Container provisioning (run once per test session) ────────────────────
|
|
56
|
+
|
|
57
|
+
APP_ROLE_NAME = "unionstation_app" # Match production DSN identity
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def provision_app_role(admin_conn: asyncpg.Connection) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Create the runtime non-owner role inside the test container. Mirrors
|
|
63
|
+
production: NOLOGIN if password-less; LOGIN with a fixed test password
|
|
64
|
+
if the test harness needs to connect as this role directly.
|
|
65
|
+
|
|
66
|
+
NOSUPERUSER and NOBYPASSRLS are the load-bearing settings. Without these,
|
|
67
|
+
the role retains FORCE RLS bypass and the fixture buys you nothing.
|
|
68
|
+
"""
|
|
69
|
+
await admin_conn.execute(f"""
|
|
70
|
+
DO $$
|
|
71
|
+
BEGIN
|
|
72
|
+
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '{APP_ROLE_NAME}') THEN
|
|
73
|
+
CREATE ROLE {APP_ROLE_NAME}
|
|
74
|
+
LOGIN
|
|
75
|
+
NOSUPERUSER
|
|
76
|
+
NOBYPASSRLS
|
|
77
|
+
NOCREATEDB
|
|
78
|
+
NOCREATEROLE
|
|
79
|
+
PASSWORD 'test_app_password';
|
|
80
|
+
GRANT USAGE ON SCHEMA public TO {APP_ROLE_NAME};
|
|
81
|
+
GRANT SELECT, INSERT, UPDATE, DELETE
|
|
82
|
+
ON ALL TABLES IN SCHEMA public TO {APP_ROLE_NAME};
|
|
83
|
+
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO {APP_ROLE_NAME};
|
|
84
|
+
END IF;
|
|
85
|
+
END $$;
|
|
86
|
+
""")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Usage example ─────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_rls_blocks_cross_org_select(db_as_app: asyncpg.Connection) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Use db_as_app — NOT db — for any RLS-policy assertion. Under the
|
|
96
|
+
SUPERUSER `db` fixture, this test would pass even if the policy were
|
|
97
|
+
deleted.
|
|
98
|
+
"""
|
|
99
|
+
await db_as_app.execute(
|
|
100
|
+
"SELECT set_config('app.current_org_id', '1', true)"
|
|
101
|
+
)
|
|
102
|
+
rows = await db_as_app.fetch("SELECT id, org_id FROM people")
|
|
103
|
+
assert all(row["org_id"] == 1 for row in rows), \
|
|
104
|
+
"RLS allowed cross-org rows under FORCE — policy is broken or role has BYPASSRLS=t"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Asynccontextmanager variant for non-pytest contexts ───────────────────
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@asynccontextmanager
|
|
111
|
+
async def as_app_role(conn: asyncpg.Connection) -> AsyncIterator[asyncpg.Connection]:
|
|
112
|
+
"""
|
|
113
|
+
Imperative variant of the fixture for scripts and one-off RLS exercises.
|
|
114
|
+
"""
|
|
115
|
+
await conn.execute("SAVEPOINT as_app_role")
|
|
116
|
+
try:
|
|
117
|
+
await conn.execute(f"SET LOCAL ROLE {APP_ROLE_NAME}")
|
|
118
|
+
yield conn
|
|
119
|
+
finally:
|
|
120
|
+
await conn.execute("ROLLBACK TO SAVEPOINT as_app_role")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── Anti-patterns ─────────────────────────────────────────────────────────
|
|
124
|
+
#
|
|
125
|
+
# 1. Using `db` fixture for RLS assertions. SUPERUSER bypass means
|
|
126
|
+
# every test passes regardless of policy correctness. Production blows up.
|
|
127
|
+
#
|
|
128
|
+
# 2. Provisioning the app role with BYPASSRLS=t "for convenience." Defeats
|
|
129
|
+
# the entire FORCE RLS deployment.
|
|
130
|
+
#
|
|
131
|
+
# 3. SET ROLE without SAVEPOINT. Test pollution: subsequent tests run under
|
|
132
|
+
# whichever role the previous test ended in.
|
|
133
|
+
#
|
|
134
|
+
# 4. Skipping ROLLBACK TO SAVEPOINT in the finally branch. Connection
|
|
135
|
+
# pooling will hand out a connection still scoped to APP_ROLE_NAME.
|
|
136
|
+
#
|
|
137
|
+
# 5. SET LOCAL ROLE inside an asyncpg pool callback (which runs outside any
|
|
138
|
+
# transaction). Use SET ROLE (session-scoped) plus explicit RESET ROLE
|
|
139
|
+
# on connection release. See field report #319 §1 — same trap surfaced
|
|
140
|
+
# in M-04c W3.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pattern: Structural SQL Sentinel (with adversarial-test discipline)
|
|
3
|
+
|
|
4
|
+
Source: Field report #319 §3. V083 sentinel #2 originally used a single regex
|
|
5
|
+
matching `current_setting(...) = ''`. Three Wave 3 reviewers independently
|
|
6
|
+
flagged that the regex misses commuted (`'' = current_setting(...)`),
|
|
7
|
+
cast (`current_setting(...)::text = ''`), IS NULL canonical
|
|
8
|
+
(`current_setting(...) IS NULL`), and coalesce-wrapped variants. Each missed
|
|
9
|
+
form is a future fail-open re-introduction the sentinel is supposed to block.
|
|
10
|
+
|
|
11
|
+
A single-form structural sentinel is a single point of failure. This pattern
|
|
12
|
+
documents the discipline: every structural sentinel has positive controls,
|
|
13
|
+
adversarial alternation tests, AND fixture-bindability proof.
|
|
14
|
+
|
|
15
|
+
Use this pattern for any SQL-shape policing — fail-open detection in RLS
|
|
16
|
+
policies, dangerous catalog reads, deprecated function calls, plaintext
|
|
17
|
+
storage in encrypted columns.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import pytest
|
|
22
|
+
from typing import Iterable
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── The Sentinel ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
# Comprehensive regex that matches all known fail-open forms. Each
|
|
28
|
+
# alternation is a CVE-class pattern that has bitten production at least once.
|
|
29
|
+
FAIL_OPEN_RE = re.compile(
|
|
30
|
+
r"""
|
|
31
|
+
(
|
|
32
|
+
# Direct equality
|
|
33
|
+
current_setting\([^)]*\)\s*=\s*'' |
|
|
34
|
+
# Commuted (Postgres doesn't canonicalize operand order)
|
|
35
|
+
''\s*=\s*current_setting\([^)]*\) |
|
|
36
|
+
# Cast on the function call
|
|
37
|
+
current_setting\([^)]*\)\s*::\s*\w+\s*=\s*'' |
|
|
38
|
+
# IS NULL form (treats unset GUC as fail-open)
|
|
39
|
+
current_setting\([^)]*\)\s*IS\s+NULL |
|
|
40
|
+
# COALESCE wrap
|
|
41
|
+
coalesce\(\s*current_setting\([^)]*\)\s*,\s*''\s*\)\s*=\s*''
|
|
42
|
+
)
|
|
43
|
+
""",
|
|
44
|
+
re.IGNORECASE | re.VERBOSE,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def policy_is_fail_open(policy_qual: str) -> bool:
|
|
49
|
+
"""Return True if the policy expression contains a known fail-open arm."""
|
|
50
|
+
return bool(FAIL_OPEN_RE.search(policy_qual))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Positive controls (must trigger) ─────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
POSITIVE_FORMS = [
|
|
56
|
+
# Direct
|
|
57
|
+
"current_setting('app.current_org_id', true) = ''",
|
|
58
|
+
# Whitespace tolerance
|
|
59
|
+
"current_setting('app.current_org_id', true) = ''",
|
|
60
|
+
# Commuted
|
|
61
|
+
"'' = current_setting('app.current_org_id', true)",
|
|
62
|
+
# Cast
|
|
63
|
+
"current_setting('app.current_org_id', true)::text = ''",
|
|
64
|
+
# IS NULL
|
|
65
|
+
"current_setting('app.current_org_id', true) IS NULL",
|
|
66
|
+
# COALESCE wrap
|
|
67
|
+
"coalesce(current_setting('app.current_org_id', true), '') = ''",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Negative controls (must NOT trigger) ──────────────────────────────────
|
|
72
|
+
|
|
73
|
+
NEGATIVE_FORMS = [
|
|
74
|
+
# The legitimate org_id check the sentinel is protecting
|
|
75
|
+
"org_id = current_setting('app.current_org_id', true)::int",
|
|
76
|
+
"org_id::text = current_setting('app.current_org_id', true)",
|
|
77
|
+
# Other unrelated comparisons
|
|
78
|
+
"deleted_at IS NULL",
|
|
79
|
+
"tenant_id = (SELECT id FROM tenants WHERE name = 'system')",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Adversarial-bindability test ──────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize("form", POSITIVE_FORMS)
|
|
87
|
+
def test_sentinel_catches_fail_open_form(form: str) -> None:
|
|
88
|
+
"""Every known fail-open variant must trip the sentinel."""
|
|
89
|
+
assert policy_is_fail_open(form), \
|
|
90
|
+
f"SENTINEL GAP: form did not trip — '{form}'"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.parametrize("form", NEGATIVE_FORMS)
|
|
94
|
+
def test_sentinel_does_not_false_positive(form: str) -> None:
|
|
95
|
+
"""Legitimate policy expressions must not trip the sentinel."""
|
|
96
|
+
assert not policy_is_fail_open(form), \
|
|
97
|
+
f"FALSE POSITIVE: legitimate form tripped — '{form}'"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Fixture-bindability proof ─────────────────────────────────────────────
|
|
101
|
+
#
|
|
102
|
+
# A structural sentinel is meaningful only if it can FAIL on a deliberate
|
|
103
|
+
# regression. Test that, too:
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_sentinel_can_bind() -> None:
|
|
107
|
+
"""
|
|
108
|
+
Construct a deliberate regression and assert the sentinel catches it.
|
|
109
|
+
If this assertion ever flips, either the regex was changed silently
|
|
110
|
+
or the fail-open form is no longer detectable. Either way, an alert
|
|
111
|
+
is mandatory.
|
|
112
|
+
"""
|
|
113
|
+
deliberate_regression = "current_setting('x', true) = ''"
|
|
114
|
+
assert policy_is_fail_open(deliberate_regression), \
|
|
115
|
+
"BINDABILITY FAILURE: sentinel cannot fail under any input — it's a no-op"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── Anti-patterns ─────────────────────────────────────────────────────────
|
|
119
|
+
#
|
|
120
|
+
# 1. SUBSTRING match instead of regex (LIKE '%...%'). Misses commuted, cast,
|
|
121
|
+
# and IS NULL forms. Field report #319 §3.
|
|
122
|
+
#
|
|
123
|
+
# 2. Single regex variant without alternation. The first migration author who
|
|
124
|
+
# writes a different form silently re-introduces the class.
|
|
125
|
+
#
|
|
126
|
+
# 3. Positive controls only. Without negative controls, false positives
|
|
127
|
+
# flood the alert channel and reviewers learn to ignore them.
|
|
128
|
+
#
|
|
129
|
+
# 4. No bindability proof. A sentinel that algebraically cannot fail is a
|
|
130
|
+
# no-op. See /docs/patterns/adr-verification-gate.md.
|
|
131
|
+
#
|
|
132
|
+
# 5. Sentinel lives in one place (CI grep) without a database-side mirror.
|
|
133
|
+
# Belt-and-suspenders: lint the policy text in CI AND assert
|
|
134
|
+
# policy_is_fail_open() against pg_policies.qual at runtime.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Context Meter — status line + awareness hook
|
|
2
|
+
|
|
3
|
+
Two small scripts that surface how full the context window is — one for the human, one for the model.
|
|
4
|
+
|
|
5
|
+
| Script | Wired to | Audience | What it does |
|
|
6
|
+
|--------|----------|----------|--------------|
|
|
7
|
+
| `voidforge-statusline.sh` | `statusLine` (settings.json) | you | Renders one line: model + a colored meter (`⟦████████░░⟧ 78%`) + tokens remaining. Green → yellow → red as the window fills. |
|
|
8
|
+
| `context-awareness-hook.sh` | `UserPromptSubmit` hook | Claude | Once usage crosses a threshold, injects "you have ~X% left, checkpoint soon" into the model's own context each turn. Silent below the threshold. |
|
|
9
|
+
|
|
10
|
+
The model can't see its own remaining context. The status line tells *you*; the hook tells *Claude* — so it can wrap up open loops and suggest `/vault` or `/seal` before compaction instead of being surprised by it.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
**Default-on.** `npx voidforge-build init` already wires both scripts into a new project's `.claude/settings.json` (warn 80% / crit 92%). Nothing to do for a fresh project.
|
|
15
|
+
|
|
16
|
+
To re-install, retune, or activate on a project that predates this feature, run **`/contextmeter`** — it chmods these scripts and merges the right block into `.claude/settings.json`. Or wire it by hand: merge `settings-snippet.json` into `.claude/settings.json`. Remove with `/contextmeter --uninstall`.
|
|
17
|
+
|
|
18
|
+
## How it reads context
|
|
19
|
+
|
|
20
|
+
- **Status line:** prefers the native `context_window` object Claude Code pipes on stdin (`used_percentage`, `context_window_size`). Falls back to deriving usage from the most recent assistant `message.usage` in `transcript_path` on older Claude Code that doesn't send the field.
|
|
21
|
+
- **Hook:** the hook stdin has no `context_window` object, so it always derives from `transcript_path` (`input_tokens + cache_read_input_tokens + cache_creation_input_tokens`).
|
|
22
|
+
- 1M-token sessions are detected automatically (usage above 200k ⇒ 1,000,000 denominator), or set `VOIDFORGE_CONTEXT_WINDOW`.
|
|
23
|
+
|
|
24
|
+
## Tuning (env)
|
|
25
|
+
|
|
26
|
+
| Var | Default | Effect |
|
|
27
|
+
|-----|---------|--------|
|
|
28
|
+
| `VOIDFORGE_CONTEXT_WINDOW` | `200000` | Denominator when the size field is absent. |
|
|
29
|
+
| `VOIDFORGE_CONTEXT_WARN_PCT` | `80` | Hook starts speaking — and the meter turns yellow — at this % used. |
|
|
30
|
+
| `VOIDFORGE_CONTEXT_CRIT_PCT` | `92` | Hook escalates to "checkpoint NOW" — and the meter turns red — at this %. |
|
|
31
|
+
|
|
32
|
+
Both scripts read the same two thresholds, so the meter's yellow/red bands stay in lockstep with the hook's warn/critical bands. `/contextmeter --warn-pct N` / `--crit-pct N` bake these into the command strings in settings.json so they persist without a shell export.
|
|
33
|
+
|
|
34
|
+
## Requirements & caveats
|
|
35
|
+
|
|
36
|
+
- **`jq`** is required. Without it the status line prints a one-line "install jq" notice and the hook no-ops — neither ever breaks your session.
|
|
37
|
+
- Only the **first line** of status-line stdout is shown by Claude Code, so the meter is deliberately single-line.
|
|
38
|
+
- **Name:** this ships as `/contextmeter`, not `/statusline` — Claude Code's native `/statusline` and `/context` commands always shadow a same-named project command (see `docs/NATIVE_CAPABILITIES.md`).
|