Last updated: 2026-05-22. Reflects the post-2C architecture
(merged into main on 2026-05-25 as commit 840ad14).
This is the technical companion to PRIVACY_POLICY.md. The privacy
policy is the customer-facing legal disclosure; this document
enumerates the implementation — what's transmitted, what's persisted,
what's logged, what subprocessors see — so security-conscious
customers can verify the privacy policy's claims against actual
behavior.
If anything in this document conflicts with the implementation, that's a bug — please file an issue.
- Default mode (
--offline): zero network calls. BrassCoders reads your files, runs scanners, writes.brass/*.yaml. Nothing leaves your machine. - Paid enrichment mode (active license, no
--no-enrichflag):- Transmits redacted finding metadata + four small file chunks (README, manifest, entrypoint, top-level filenames) to our gateway over TLS
- Persists nothing customer-derived on our servers except: sha256-keyed embedding vectors (one-way), and license/quota metadata (your license key hash + token counters)
- No raw code, no full files, no findings text is stored at rest on our infrastructure
- All inter-service hops use TLS 1.2/1.3.
- License keys travel in HTTP headers, never URLs — they can't leak into URL-bearing exception messages or proxy logs.
If your threat model requires zero data egress, --offline is the
contract. If you need enrichment, the rest of this document tells you
exactly what flows where.
For each file under the project root you point BrassCoders at, the CLI:
- Skips files in the exclusion list (
.git,__pycache__,node_modules,.venv,venv,build,dist,.brass,.next,.nuxt,.svelte-kit, etc.). Seesrc/brass/scanners/file_prefilter_scanner.py. - Skips files whose resolved path falls outside the project root,
even via symlink. See
src/brass/core/path_safety.py. - Skips files larger than 1 MiB.
- Reads the file content into memory.
- Runs the relevant scanners (security, privacy, code quality, performance, secrets, content moderation).
BrassCoders does not read files outside your project root, and refuses to follow symlinks pointing outside.
A .brass/ directory inside the project root, with these files:
ai_instructions.yaml— top-level summary for AI consumersdetailed_analysis.yaml— every finding, grouped by typefile_intelligence.yaml— findings collated per filesecurity_report.yaml— security-only viewstatistics.yaml— aggregate metricsprivacy_analysis.yaml— present only when PII findings existbrass.log— diagnostic log
.brass/ is created with permissions 0700; YAML files are written
with 0600 (POSIX). On Windows, BrassCoders relies on filesystem ACLs.
The privacy scanner exists to detect sensitive data; writing the raw
matches into .brass/ would defeat the purpose. Two enforcement
layers:
- At the source.
Brass2PrivacyScanner._redact_pii_metadataruns after suppression heuristics and before the finding is returned. Replaces the matched value with a masked version (e.g.4111****1111), dropscontext_line/code_snippet, and clearsFinding.code_snippetfor any privacy finding. - At the boundary.
BaseYAMLBuilder.sanitize_metadata_for_serializationstrips known privacy-sensitive metadata keys (matched_text,code_snippet,context_line,raw_match,context) from any privacy-type finding before YAML serialization.
Hardcoded credentials detected by other scanners are redacted in-place
inside the source line via AIAuthPatternAnalyzer._redact_secret_in_line
before persisting.
The secret scanner records only the secret type and a short hash for deduplication. The secret value itself is never written to disk by BrassCoders.
If you scan with an active paid license and don't pass --no-enrich,
the CLI calls our gateway once per scan. Here's the complete inventory.
{
"license_key": "AAAA-BBBB-...",
"instance_id": "host-or-machine-id",
"raw_files": {
"readme": "first 5000 chars of README.md (if present)",
"manifest": "first 2000 chars of pyproject.toml / package.json / etc",
"entrypoint": "first 3000 chars of main.py / index.ts / etc",
"filenames": ["main.py", "src/lib.py", ...]
},
"findings": [
{
"id": "f0",
"text": "<sanitized + bounded representation of the finding>",
"type": "security|privacy|code_quality|...",
"title": "<category label, scrubbed of any matched value>",
"file_path": "src/auth.py",
"severity": "critical|high|medium|low|info"
},
...
],
"options": { "rerank_top_n": 200, "dedup_threshold": 0.85 }
}About findings[].text — this is the embedded representation, built
by _finding_to_text in src/brass/enrichment/filter.py. For
sensitive finding types (SECURITY, PRIVACY, SECRET, CREDENTIAL, PII),
it includes ONLY file path + line number + type + scrubbed title.
Description, snippet, and matched-text fields are NEVER included for
sensitive types — defense in depth against a scanner regression
silently exfiltrating a secret. For non-sensitive types
(CODE_QUALITY, ARCHITECTURE, PERFORMANCE), title + first 1.2KB of
description + first 300 chars of code snippet are included.
About raw_files — these are file content chunks. They are
explicitly read by the CLI's gather_raw_files()
(src/brass/enrichment/project_signature.py), which refuses symlinks
to prevent a malicious project from steering /etc/passwd-like
content into the wire payload. Customer-side content-length caps
(5K/2K/3K/80 entries) bound the payload.
{
"findings": [
{ "id": "f5", "rank_score": 0.95, "cluster_size": 1 },
{ "id": "f0", "rank_score": 0.89, "cluster_size": 3 },
...
],
"tokens_used": 1234,
"quota_remaining": 4998766,
"quota_period_end": "2026-06-22T..."
}Survivors only — dropped findings are implicit. Ordered by rank_score
descending. cluster_size > 1 means this surviving finding absorbed
cluster_size - 1 semantically-duplicate siblings.
GET /api/quota — returns the customer's remaining token allowance.
License credentials travel in the X-BrassCoders-License-Key and
X-BrassCoders-Instance-Id HTTP headers, not in the URL. This prevents
the license key from appearing in network-error exception strings, CI
logs, or HTTP proxy access logs.
All gateway state lives in Upstash Redis. There is no persistent database. Vercel functions are stateless — no disk state survives between requests.
| Key pattern | Value | TTL | Customer-derived? |
|---|---|---|---|
embed_cache:voyage-code-3:512:<sha256(finding_text)> |
{ vector: number[512], dim, model } |
7 days | Vector is one-way derived from finding text. Key is sha256 — original text is not recoverable from key or value. Vectors enable similarity comparison but not text reconstruction. |
license_cache:<sha256(license_key + instance_id)> |
{ valid, status, expires_at, customer_email, product_name } |
15 seconds | Contains your email address. ~5 entries deep at any time given the 15-second window. |
quota:<sha256(license_key)> |
{ monthly_remaining, topup_remaining, period_start, period_end, total_used_lifetime } |
Persistent (per-license) | License key hash + token counters. No PII beyond what LemonSqueezy already has. |
revoked:<sha256(license_key)> |
true |
Persistent until cleared by webhook (or set with TTL on grace periods, but we currently use permanent) | Set by LS webhook on subscription_expired / subscription_payment_failed / subscription-order_refunded. Short-circuits future validateLicense calls without hitting LS, so revocations propagate within webhook-delivery latency (seconds) instead of waiting up to the 15s license_cache TTL. |
cust_to_license:<lemonsqueezy_customer_id> |
<license_key string> |
Persistent | Set by LS webhook on license_key_created. Used by other webhook events (which carry only customer_id) to resolve back to the license key. Stable for the lifetime of the license. |
order_processed:<lemonsqueezy_order_id> |
true |
10 minutes | Idempotency guard on order_created (topup detection). LS retries failed deliveries 3x with exponential backoff; this flag prevents double-processing on retry. |
order_refunded_processed:<lemonsqueezy_order_id> |
true |
10 minutes | Same pattern for order_refunded events. |
.gc.lock, .schema (Pysa cache only, customer machine) |
n/a | n/a | Customer-side only. Not on our infrastructure. |
Note: cumulative lifetime token usage is not a separate Redis key — it's stored as the total_used_lifetime field inside the quota:<sha256> JSON value (updated atomically by the same Lua script that decrements monthly/topup buckets).
Explicitly not persisted on our infrastructure:
- Raw finding text
- The
raw_filespayload (README/manifest/entrypoint chunks) - Code snippets
- Customer file paths
- Customer source code
- Anything customer-source-derived beyond the embedding vector
Upstash encrypts data at rest as a service guarantee.
The gateway logs to Vercel's serverless function logs (encrypted at rest; 7-day retention on free tier, 30 days on Pro):
-
[cache] pipeline.set failed at index ...— cache write failures. No finding content; only the failed index + Redis error. -
[enrich] upstream error: <e.message>, <e.status>— only on Voyage API failures.Caveat: Voyage's error messages occasionally include partial echo of the failing input (e.g., for "input too long" errors). In the rare case a Voyage call fails on a long finding-text input, a short snippet of that text could land in Vercel's logs. Not customer-visible, but disclosed here for completeness.
We do not log:
- Successful enrichment requests
- Finding text on the happy path
- License keys (always passed as headers, redacted from any log we do emit)
- IP addresses (beyond what Vercel automatically captures for routing)
Third-party services that touch customer data during normal operation:
| Subprocessor | What they see | Their stance |
|---|---|---|
| Voyage AI (embedding + rerank) | Finding text (privacy-redacted before transmission) + project signature, during the embed and rerank API calls. Per-token billing. | Public commitment: "zero data retention" — API calls are not used for training. Verify their current TOS for the latest commitment. |
| Vercel (gateway hosting + edge network) | All HTTPS traffic transits Vercel infrastructure. Logs request metadata, error logs (see "What we log" above). | SOC 2 Type II certified. Logs encrypted at rest. Default region: US-East. |
| Upstash (Redis backend) | All Redis reads/writes for embedding cache, license cache, quota state, rate limits. | SOC 2 Type II certified. Data encrypted at rest. |
| LemonSqueezy (license issuance + payment) | License keys, customer email, purchase records, activation metadata. Does not see findings or code. | SOC 2 Type II certified. They own the customer-payment relationship and tax compliance. |
LemonSqueezy POSTs signed event notifications to our gateway whenever subscription / order / license-key state changes server-side. The gateway translates those into Upstash state mutations.
What's transmitted from LS to our gateway, per event:
- Customer email (already known to us via the license-validate path)
- Customer ID, subscription ID, order ID (LS-internal numeric IDs)
- License key string (only on
license_key_createdevent) - Subscription status (active, cancelled, expired, past_due, paused)
- Period start / end / renews_at / ends_at timestamps
- Variant ID + product ID (so the gateway can distinguish subscription orders from topup orders)
What the gateway does with each event is enumerated in the webhook
handler source (gateway/api/webhooks/lemonsqueezy.ts). Briefly: it
sets/clears revoked flags, applies topup credits, and caches the
customer-ID-to-license-key mapping. No finding text or source code
ever transits this path — those flow through /api/enrich only,
and LS never sees them.
Signature verification: every webhook delivery carries an
x-signature header computed as HMAC-SHA256 of the raw body using
our LS webhook signing secret. The gateway rejects (401) any payload
with an invalid or missing signature, so an attacker cannot forge
state mutations even with knowledge of the webhook URL.
- ❌ Train on customer code. Voyage's zero-data-retention commitment applies; we do not retain, mine, or train any model on customer findings or source.
- ❌ Share customer code with anyone outside the subprocessor list above. No analytics SDKs, no error trackers that capture request bodies, no third-party CDN that sees finding content.
- ❌ Sell or share customer email beyond what LemonSqueezy needs for the payment relationship.
- ❌ Track customer scans, project signatures, or finding patterns for product analytics. The gateway counts tokens for billing; that's it.
- ❌ Make outbound network calls in
--offlinemode. Every CLI command honors--offlineas a hard contract.
These are common questions from security-conscious procurement processes. Honest current state:
| Standard / certification | Status |
|---|---|
| TLS 1.2/1.3 in transit | ✅ All hops |
| No persistent storage of customer code on our servers | ✅ Verifiable in gateway/lib/cache.ts, gateway/lib/enrich.ts — no DB writes of finding text or raw files |
| Subprocessor list documented | ✅ This document |
| Privacy policy in place | ✅ cli/docs/PRIVACY_POLICY.md |
--offline mode for air-gapped use |
✅ Documented contract |
| SOC 2 Type I | ❌ Not certified |
| SOC 2 Type II | ❌ Not certified |
| ISO 27001 | ❌ Not certified |
| HIPAA / BAA | ❌ BrassCoders is not approved for protected health information |
| GDPR-compliant Data Processing Agreement | ❌ Template not yet drafted |
| Penetration test report on file | ❌ |
| Bug bounty program | ❌ — responsible disclosure via brass@coppersuncreative.com per SECURITY.md |
| Customer-managed encryption keys (BYOK) | ❌ |
| Region pinning (EU-only, US-only data residency) | ❌ — default US-East via Vercel |
| Customer-accessible audit logs | ❌ |
If your procurement process requires any of these, contact
brass@coppersuncreative.com to discuss timeline.
| Choice | How |
|---|---|
| Run fully offline (no enrichment, no network) | brasscoders --offline scan |
| Enable enrichment but skip the package-hallucination check | default (the package-hallucination check is opt-in per scan) |
| Stop using enrichment entirely | brasscoders license deactivate (releases the activation; on-disk record at ~/.brass/license is removed) |
| Request deletion of your license and quota state from our gateway | Email brass@coppersuncreative.com. We'll remove your license + quota Redis entries. Embedding cache entries auto-expire within 7 days (and are not recoverable to text anyway). |
BrassCoders's security model centers on three scenarios:
-
A user scanning untrusted source code. Symlink-escape and git-config-inheritance attacks (CVE-2022-24765 class) are neutralized via path-safety enforcement and sandboxed subprocess env. See
cli/SECURITY.mdfor closed findings. -
A user with a BrassCoders license trying to spoof a higher-tier license. LemonSqueezy owns the license-status source of truth; the on-disk record at
~/.brass/licenseis a cache that re-validates weekly. Tampering doesn't grant tier escalation because there's only one tier (seeMEMORY.mdfor the single-tier product decision). -
Network-borne attacks against the (opt-in)
--check-package-hallucinationregistry calls. Lookups go to PyPI / npm / pkg.go.dev. The check is off by default.
Detailed scope, in-scope/out-of-scope, and closed findings are in
cli/SECURITY.md.
Email brass@coppersuncreative.com with description and reproduction
steps. We treat the following as launch-blocking:
- Unauthorized data egress
- Raw PII or credential material persisted in
.brass/artifacts - Path traversal letting a scanned project read outside its root
- Code execution from scanning a malicious project
- License-system attacks
Do not open a public GitHub issue for any of the above until a fix has shipped.