Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
bin/
.git/
*.jsonl
*.test
coverage.*
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Every change needs review from the maintainer.
* @makoydev
24 changes: 24 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
version: 2
updates:
# Keeps SHA-pinned Actions current; Dependabot updates the SHA and the
# version comment together.
- package-ecosystem: github-actions
directory: /
target-branch: next
schedule:
interval: weekly
labels:
- dependencies
groups:
actions:
update-types:
- minor
- patch

- package-ecosystem: gomod
directory: /
target-branch: next
schedule:
interval: weekly
labels:
- dependencies
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CI

on:
pull_request:
branches:
- main
- next
push:
branches:
- main
- next

permissions:
contents: read

jobs:
test:
name: Lint and test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false

# gofmt, go vet, go test -race, govulncheck and a build (see Makefile)
- name: Run every check
run: make check

container:
name: Container image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# Builds the deployment image and runs it the way Fly.io will: as the
# non-root user, writing the audit log to a volume at /data.
- name: Build and smoke-test the image
run: |
docker build --build-arg VERSION=ci -t discreet:ci .
test "$(docker inspect --format '{{.Config.User}}' discreet:ci)" = "nonroot:nonroot"
docker volume create discreet-data
docker run -d --name discreet -p 8080:8080 -v discreet-data:/data \
-e DISCREET_HMAC_KEY="$(openssl rand -hex 32)" discreet:ci
curl -fsS --retry 10 --retry-delay 1 --retry-all-errors http://localhost:8080/healthz
out=$(curl -fsS http://localhost:8080/demo/run -H 'Content-Type: application/json' \
-d '{"purpose":"claims summary","text":"Member S1234567D on 9123 4567"}')
printf '%s' "$out" | grep -q '"sent_to_model":"Member <NRIC_1> on <PHONE_1>"'
docker exec discreet /discreet audit verify -log /data/discreet-audit.jsonl
docker logs discreet 2>&1 | grep -vq 'S1234567D'

55 changes: 55 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: CodeQL

on:
pull_request:
branches:
- main
- next
push:
branches:
- main
- next
schedule:
- cron: '41 7 * * 3'

permissions:
contents: read

jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
include:
- language: go
build-mode: autobuild
- language: actions
build-mode: none
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up Go
if: matrix.language == 'go'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: false

- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: /language:${{ matrix.language }}
59 changes: 59 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Deploy demo

# Deploys the public mock-mode demo to Fly.io from main only, after
# Michael's merge. Does nothing until the FLY_API_TOKEN secret exists.
on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy
cancel-in-progress: false

jobs:
deploy:
name: Deploy to Fly.io
runs-on: ubuntu-latest
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
steps:
- name: Skip without a Fly.io token
id: gate
run: |
if [ -z "$FLY_API_TOKEN" ]; then
echo "::notice::FLY_API_TOKEN is not set; skipping deployment (see docs/DEPLOY.md)."
echo "run=false" >> "$GITHUB_OUTPUT"
else
echo "run=true" >> "$GITHUB_OUTPUT"
fi

- name: Checkout
if: steps.gate.outputs.run == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up flyctl
if: steps.gate.outputs.run == 'true'
uses: superfly/flyctl-actions/setup-flyctl@ed8efb33836e8b2096c7fd3ba1c8afe303ebbff1 # 1.6

- name: Deploy
if: steps.gate.outputs.run == 'true'
run: flyctl deploy --remote-only --build-arg VERSION="${GITHUB_SHA::7}"

- name: Smoke test the live demo
if: steps.gate.outputs.run == 'true'
run: |
app=$(sed -n 's/^app = "\(.*\)"/\1/p' fly.toml)
url="https://$app.fly.dev"
curl -fsS --retry 5 --retry-delay 5 --retry-all-errors "$url/healthz"
out=$(curl -fsS "$url/demo/run" -H 'Content-Type: application/json' \
-d '{"purpose":"claims summary","text":"Member S1234567D on 9123 4567"}')
printf '%s' "$out" | grep -q '"sent_to_model":"Member <NRIC_1> on <PHONE_1>"'
printf '%s' "$out" | grep -q '"upstream":"mock"'
echo "Live demo OK: $url"
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Changelog

All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).

## [Unreleased]

## [0.1.0] - 2026-10-06

First release: Discreet, a privacy gateway that replaces Singapore personal data with placeholders before a request reaches an AI model and restores it in the answer, refuses automated eligibility decisions, and keeps a tamper-evident audit log. Reviewed and approved by Michael Mendoza on 2026-10-06.

### Added

- Go 1.27 project skeleton: the `discreet` command with `serve`, `audit verify`, `audit export` and `version`, each describing itself until it is built (issue D1).
- CI on every pull request: `gofmt`, `go vet`, `go test -race`, `govulncheck` and a build (`make check`), plus CodeQL for Go and the workflows. All Actions pinned by commit SHA.
- Evidence files: `CONTROLS.md`, `THREAT_MODEL.md`, `EVALS.md`, `RISKS.md`, `docs/HOW-THIS-WAS-BUILT.md` and the first two decision records.
- Vetted reviews every pull request (shadow mode until 2026-10-14).
- Detection engine (`internal/detect`, issue D3): implements the sg-pii-rules specification with Go's `regexp` (RE2), including the five named validators (NRIC/FIN, NRIC look-alikes, payment cards, postal sectors, calendar dates), `value` groups and the overlap rule. Passes all 369 shared conformance cases.
- Evidence pack (issue D11): `CONTROLS.md` v0.1 with section-level primary sources, the threat model reviewed with residual risks, `RISKS.md` updated, `docs/CV-NUMBERS.md`.
- Deployment (issue D10): a distroless, non-root container image pinned by digest and smoke-tested in CI on every pull request; `fly.toml` for a mock-only public demo in Singapore that sleeps when idle; a deploy workflow from `main` that waits for the `FLY_API_TOKEN` secret and smoke-tests the live URL; `docs/DEPLOY.md` with the one-time steps only Michael can do (ADR 0009).
- Benchmark (issue D9, `make bench`): 5,000 synthetic samples from a generator written independently of the rules, scored per entity and per writing style, plus latency against a baseline. Results in `EVALS.md`: 76.5% recall overall (100% on every supported style), 92.3% precision (all false alarms are bare eight-digit numbers as `PHONE`), about 3 ms added per request, almost all of it the audit write flushed to disk.
- Four-pane demo page (issue D8) at `/`: what the app sent, what the AI saw, what it answered, and what the app got back, plus that request's audit record. Five made-up scenarios (claims, appointment, card dispute, mistyped NRIC, a refused eligibility decision). It runs through the same pipeline as the API, always on the mock model, even for token holders; strict Content-Security-Policy; works on phones, light and dark.
- A real model behind access tokens (issue D6): an OpenAI-compatible client (OpenAI, or Ollama with `max_tokens`) used only for callers with a valid `DISCREET_ACCESS_TOKENS` token; everyone else gets the mock. One pinned model with checked prices (`gpt-6-luna`); each request reserves its worst-case cost (at most US$0.0076) against a hard daily budget (default US$0.20) recomputed from the audit log; 30 requests a minute per client address (ADR 0007). No test calls a paid API.
- The gateway (`internal/gateway`, issue D5): `discreet serve` runs an OpenAI-compatible `POST /v1/chat/completions` (non-streaming). Every message is protected in one session before the model sees it and restored afterwards; every answer ends with a disclosure footer; every request, allowed or not, is recorded in the audit log, and Discreet never answers a request it couldn't record. A free mock model is the default. Callers must send `X-Discreet-Purpose`; automated eligibility and benefits decisions are refused with HTTP 403 (ADR 0006). Only the model, messages, token limit and temperature are forwarded. Tested with OpenAI's official Python library by changing only `base_url`.
- Tamper-evident audit log (`internal/audit`, issue D7): append-only JSON lines, each record holding the SHA-256 of the previous one and HMAC-SHA-256 hashes of prompt and response (never the text). `discreet audit verify` names the first changed, missing or reordered record; `discreet audit export -csv` exports a verified log. An existing broken log is refused at start-up (ADR 0005).
- Placeholders and the vault (`internal/protect`, `internal/vault`, issue D4): personal data is replaced per request with numbered placeholders such as `<NRIC_1>` (the same value gets the same placeholder), redacted as `[REDACTED_CARD]`, or the request is blocked, following a YAML policy (`internal/protect/default-policy.yaml`). Values are kept encrypted in memory with AES-256-GCM for at most 10 minutes and restored only into the same request's answer (ADR 0004). First third-party module: `go.yaml.in/yaml/v3` v3.0.5.
- sg-pii-rules `v0.2.0-rc.1` vendored in `third_party/sgpiirules` and embedded in the binary; a test re-checks every file against `SHA256SUMS` (ADR 0003). `scripts/vendor-sg-pii-rules.sh <tag>` updates it.
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@ Programme-wide rules are in `../CLAUDE.md` (loaded automatically) and the full b

Discreet is a privacy-preserving LLM gateway written in Go. Development starts in Milestone 2 (due 22 Nov 2026): proxy with a mock and one real upstream, detectors from `sg-pii-rules` (vendored, checksummed), tokenise and rehydrate, a hash-chained audit log with `verify`, a four-pane demo UI, a benchmark in `EVALS.md`, and a public mock-mode deployment.

Until then this repository only holds the Vetted pilot: every pull request is reviewed by Vetted, in shadow mode until 2026-10-14 and opt-in afterwards. Work goes through pull requests into `next` (ADR 0009 in Vetted); only Michael merges `next` into `main`.
Development started on 2026-10-04 (issue D1). Every pull request is reviewed by Vetted, in shadow mode until 2026-10-14 and opt-in afterwards. Work goes through pull requests into `next` (ADR 0002); only Michael merges `next` into `main`.

- Go 1.27, standard library only (ADR 0001); any new module needs an ADR and an exact version.
- Run `make check` (gofmt, vet, race tests, govulncheck, build) before every commit; never commit past a failure.
- Tests use synthetic data only; build secret- and NRIC-shaped strings at runtime.
- `third_party/sgpiirules` is a vendored sg-pii-rules release: never edit it by hand; update with `scripts/vendor-sg-pii-rules.sh <tag>` (ADR 0003).
- Issues live in `makoydev/vetted` under the "M2: Discreet v0.1" milestone (D1 = vetted#30).
26 changes: 26 additions & 0 deletions CONTROLS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Controls matrix (v0.1)

What each part of Discreet supports, with source and section. **P** = primary source, **S** = secondary. The citations below were read from the primary sources for [Vetted's controls matrix](https://github.com/makoydev/vetted/blob/next/CONTROLS.md#sources) on 2026-09-30 and are reused here where Discreet implements the same principle; "by analogy" marks guidance written for a neighbouring case. No row claims compliance with a law or framework: each says which principle a feature supports.

| # | Feature | Supports | Source (grade) | Evidence |
| --- | --- | --- | --- | --- |
| 1 | Personal data replaced with placeholders before the model sees it: NRIC/FIN (including mistyped), phone, email, card, postal code, unit number, date of birth; values restored only into the same request's answer | Protecting personal data; not disclosing NRIC numbers that aren't needed; pseudonymising before use in AI | PDPA 2012 s.24 Protection of personal data (P) · PDPC _Advisory Guidelines on Use of Personal Data in AI Recommendation and Decision Systems_ ¶7.2, pseudonymise as a basic control (P, by analogy) · PDPC _Advisory Guidelines for NRIC and other National Identification Numbers_ §3.1 (P) · OWASP LLM02:2025 Sensitive Information Disclosure (P) · IMDA _Starter Kit for Testing LLM-Based Applications_ §3.5 Data Leakage, which names NRIC (P) | 369/369 shared conformance cases; gateway test that the model never receives a planted value; EVALS benchmark |
| 2 | Values kept only encrypted in memory (AES-256-GCM, per-process key), deleted when the request ends, expired after 10 minutes, never logged | Protection of personal data in the processor itself | PDPA s.24 (P) · CSA _Guidelines on Securing AI Systems_ (Oct 2024) 2.3, protect data and logs (P) | `internal/vault` tests; log scan test (ADR 0004) |
| 3 | Only the model, messages, token limit and temperature are forwarded; tools, images and other fields are dropped or refused | No personal data in fields the gateway doesn't inspect; data minimisation towards the provider | OWASP LLM02:2025 (P) · PDPA s.26 transfer outside Singapore, and Personal Data Protection Regulations 2021 regs 9–12 (P) | Gateway tests (ADR 0006) |
| 4 | Callers must declare a purpose; automated eligibility and benefits decisions are refused before any model call and recorded | People stay accountable for decisions about people | IMDA _Model AI Governance Framework for Generative AI_ (2024), Accountability, p.6 (P) · IMDA _Model AI Governance Framework for Agentic AI_ v1.5 §2.2 human accountability, p.25 (P, by analogy) · PDPC AI Advisory Guidelines (P, by analogy: Discreet refuses rather than supports the decision) | Six refusal phrasings tested; refusals in the audit log (ADR 0006) |
| 5 | Disclosure footer on every answer naming the Discreet request | Transparency to the people reading AI output | MGF for GenAI, Content Provenance, p.23, and Trusted Development and Deployment ("food label"), p.12 (P) | Gateway test |
| 6 | Hash-chained audit log: every request, allowed or refused, with counts, model, cost and HMAC-SHA-256 fingerprints, never text; `audit verify`; no answer without a record | Traceability and incident investigation | CSA Guidelines 4.1 and 2.3, which name prompts and logs (P) · MGF for GenAI, Incident Reporting, p.16 (P) · ISO/IEC 42001:2023 A.6.2.8 AI system event logs (S) | Tamper tests: changed byte, deleted record, swapped records, rewritten record (ADR 0005) |
| 7 | Real model only for access tokens; one pinned, priced model; worst-case cost reserved against a hard daily budget that survives restarts; per-address rate limit | Bounding cost and abuse | OWASP LLM10:2025 Unbounded Consumption (P) · CSA _Companion Guide on Securing AI Systems_ 4.1.2 (P) | Tests against a fake provider, including 100 parallel requests (ADR 0007) |
| 8 | Shared conformance suite and an independent 5,000-sample benchmark with published false positives and misses | Testing and assurance; disclosing limitations | MGF for GenAI, Testing and Assurance, p.19 (P) · Starter Kit §3.5 and §3.7.3 continuous re-testing (P) · NIST SSDF PW.8.2 (P) | EVALS.md |
| 9 | SHA-pinned Actions, images pinned by digest, one third-party module, `govulncheck`, CodeQL, vendored rules verified by SHA-256, all required checks | Secure supply chain | NIST SSDF PW.4.1, PW.4.4, PS.2.1 (P) · OWASP LLM03:2025 Supply Chain (P) · CSA Guidelines 2.1 (P) | CI; `third_party/sgpiirules` checksum test (ADR 0003) |
| 10 | Synthetic data only, in tests, fixtures, the benchmark and the demo | No real personal data in testing | NIST AI 600-1 MG-2.2-009 (P) · brief non-negotiable 2 | Seeded generators; demo notice |
| 11 | Public demo is mock-only by construction, in a distroless non-root container | Public endpoints never reach a paid model | Brief non-negotiable 5 · OWASP LLM10:2025 (P) | `fly.toml`, CI container job (ADR 0009) |

## Gaps (planned for Milestone 3 or later)

- **Hold-for-review queue** with reviewer decisions and override rates (IMDA MGF for Agentic AI asks for monitoring human override rates and response times).
- **Policy presets** `sg-healthcare` and `sg-finance` (MAS FEAT, MOH AI in Healthcare Guidelines, HIPAA Safe Harbor identifiers).
- **Project Moonshot** data-disclosure and adversarial-prompt run (IMDA Starter Kit).
- **Person names** (needs NER) and the writing styles the benchmark shows the rules miss.
- **Anchoring the audit log's head hash** outside the server (ADR 0005).
- Each row above should be re-read against the current edition of its source before v1.0.
19 changes: 19 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Discreet's container: a static Go binary on Google's distroless image,
# running as a non-root user. Base images are pinned by digest.
FROM golang:1.27.1-trixie@sha256:3b77fc618ec235a1ab412de7737f120dd507c57e8d87de4cbb7994fb94275ed5 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=0.0.0-dev
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/discreet ./cmd/discreet \
&& mkdir -p /out/data

FROM gcr.io/distroless/static-debian13:nonroot@sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3
COPY --from=build /out/discreet /discreet
# /data holds the audit log; owned by the non-root user so it can write.
COPY --from=build --chown=nonroot:nonroot /out/data /data
USER nonroot:nonroot
ENV DISCREET_ADDR=:8080 DISCREET_AUDIT_LOG=/data/discreet-audit.jsonl
EXPOSE 8080
ENTRYPOINT ["/discreet", "serve"]
Loading
Loading