Status: proposed for #21 Goal: optional at-rest protection for PII-bearing local run artifacts without making run listing or cleanup miserable.
Sourcerer writes run artifacts under runs/<date-role>/. Today these files are gitignored but plaintext:
candidates.json— high sensitivity: names, URLs, evidence, emails/phones/addresses when adapters provide them, retention metadata.checkpoint.json— high sensitivity: phase outputs can include discovered/enriched candidates and PII.- output files such as Markdown/CSV/JSON reports — high sensitivity when they include candidate details.
run-meta.json— low sensitivity if kept to run id, role name, timings, cost, counts, status, prompt versions.
Plaintext is acceptable for a local single-user dev posture, but not for shared Macs, synced folders, servers, or hosted deployments.
- Opt-in first. Do not surprise existing local users by making old runs unreadable.
- Encrypt PII-bearing artifacts, not ergonomic metadata.
run-meta.jsonstays plaintext and intentionally non-sensitive sosourcerer runs listremains fast and useful. - Fail closed when encryption is enabled. If a protected artifact cannot be decrypted, commands should stop with an actionable error rather than silently treating the run as absent.
- No homegrown crypto. Use Node
cryptoprimitives: AES-256-GCM with random nonce and authentication tag. - Key material never goes in the repo or run directory. Store only non-secret key ids / encryption metadata beside encrypted files.
- Migration is explicit. Existing plaintext runs remain readable; encryption can be applied with a migration command later.
Add a runArtifacts.encryption section to ~/.sourcerer/config.yaml:
runArtifacts:
encryption:
enabled: false
keyProvider: env # env | keychain-later
keyEnv: SOURCERER_ARTIFACT_KEY
keyId: local-defaultInitial implementation should support only keyProvider: env.
SOURCERER_ARTIFACT_KEYshould be a base64url or base64 encoded 32-byte key.sourcerer initcan generate and print a one-time key export command, but should not write the key into the repo.- Future macOS-specific work can add Keychain support without changing artifact format.
Protected files are written as an envelope with an .enc.json suffix:
{
"version": 1,
"algorithm": "AES-256-GCM",
"keyId": "local-default",
"nonce": "base64url-12-bytes",
"tag": "base64url-16-bytes",
"createdAt": "2026-07-03T00:00:00.000Z",
"plaintextFilename": "candidates.json",
"ciphertext": "base64url-ciphertext"
}Plaintext file behavior when encryption is enabled:
| Logical artifact | Plaintext path | Encrypted path | Notes |
|---|---|---|---|
| Candidates | candidates.json |
candidates.json.enc.json |
Do not leave plaintext twin after successful encrypted write. |
| Checkpoint | checkpoint.json |
checkpoint.json.enc.json |
Needed because phase outputs can contain PII. |
| Markdown report | report.md |
report.md.enc.json |
Output adapters should opt into protected writes for candidate reports. |
| CSV/JSON export | *.csv, *.json |
*.csv.enc.json, *.json.enc.json |
Depends on output adapter sensitivity. |
| Run metadata | run-meta.json |
none | Keep plaintext; audit fields to ensure no PII. |
Introduce a small artifact I/O boundary, ideally in packages/core/src/run-artifacts.ts or a new packages/core/src/artifact-store.ts:
interface ArtifactStore {
writeText(runDir: string, filename: string, content: string, options?: { sensitive?: boolean }): Promise<void>;
readText(runDir: string, filename: string, options?: { sensitive?: boolean }): Promise<string>;
}Behavior:
- When encryption disabled: read/write the existing plaintext filename.
- When encryption enabled and
sensitive: true: write/read the.enc.jsonenvelope. - When encryption enabled and reading: prefer encrypted file; if only plaintext exists, read plaintext and mark it as legacy plaintext in logs.
- When encryption disabled and only encrypted exists: fail with
Encrypted artifact found but artifact encryption is disabled or no key is configured.
First implementation slice should keep scope narrow:
packages/core/src/artifact-encryption.ts- key parsing
- AES-GCM encrypt/decrypt
- envelope schema validation
- unit tests proving ciphertext does not contain raw PII
packages/core/src/artifact-store.ts- encrypted/plaintext read-write adapter
- fallback and error messages
apps/cli/src/run-loader.tsloadCandidates/writeCandidatesuseArtifactStoreforcandidates.json
packages/core/src/checkpoint.tssaveCheckpoint/loadCheckpointaccept optional artifact store or encryption options
apps/cli/src/commands/candidates.ts- purge reads/writes via the same store so encrypted artifacts preserve purge semantics
- Output adapters
- candidate-detail exports should use protected writes;
run-meta.jsonremains plaintext
- candidate-detail exports should use protected writes;
run-meta.json should remain plaintext but must be audited to exclude PII. Safe fields:
runIdroleName(borderline but useful; acceptable for local run listing)startedAt,completedAt,status, duration- phase timing and cost numbers
- candidate counts
- prompt versions
Do not add candidate names, emails, profile URLs, evidence snippets, or source URLs to run-meta.json.
sourcerer candidates purge --expired must work for encrypted and plaintext runs:
- List runs from plaintext
run-meta.json. - Load candidates through the artifact store.
- Redact expired PII in memory.
- Write candidates back through the artifact store.
- Preserve encryption state: encrypted input stays encrypted; if encryption is enabled for a legacy plaintext input, write encrypted output and remove plaintext only after the encrypted write succeeds.
Fail closed if a run has encrypted candidates but the key is unavailable.
Add an explicit future command:
sourcerer runs encrypt --all
sourcerer runs decrypt --run <run-id> --yesMigration rules:
- Dry-run by default: show affected runs and files.
- Encrypt command writes
.enc.jsonfirst, verifies decrypt/readback, then deletes plaintext. - Decrypt command requires
--yesand prints a warning because it reintroduces plaintext PII. - Existing plaintext runs remain readable until the operator opts in.
| Failure | Behavior |
|---|---|
| Encryption enabled but key missing | Abort before writing sensitive artifacts; explain SOURCERER_ARTIFACT_KEY. |
| Wrong key / auth tag mismatch | Abort with Artifact decryption failed; key may be wrong or file corrupted. |
| Encrypted + plaintext twins exist | Prefer encrypted; warn that plaintext twin should be removed. |
| Encrypted write succeeds but plaintext deletion fails | Warn loudly; command exits non-zero for migration, but normal writes should not create plaintext first. |
| Run listing sees encrypted candidates | Listing still works from run-meta.json; detailed candidate commands require key. |
Minimum implementation tests:
- AES-GCM round trip returns original JSON.
- Ciphertext envelope does not contain a sentinel email such as
alice@example.com. - Decrypting with the wrong key fails.
writeCandidateswith encryption enabled does not create plaintextcandidates.json.loadCandidatesreads encrypted candidates.purge --expiredredacts encrypted candidates and writes them back encrypted.- Legacy plaintext candidates remain readable when encryption is enabled.
- Crypto primitive + tests — no CLI behavior change.
- Candidate artifact encryption behind env/config — closes the biggest PII file first.
- Checkpoint encryption — covers interrupted/resumable runs.
- Purge compatibility — encrypted read/write round trip for retention operations.
- Output adapter protection — JSON/CSV/Markdown reports.
- Migration commands — encrypt/decrypt existing local runs.
This order gives fast security lift without turning the whole run-management system into a rewrite.