Skip to content

Commit 4e4d1df

Browse files
chrisleekrclaude
andauthored
feat(sanitizer): configurable secret policy, PEM/JWT detection, honest docs (#186)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b9d39ae commit 4e4d1df

14 files changed

Lines changed: 335 additions & 50 deletions

File tree

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ Daemon installation paths per OS, log locations, and the configuration table liv
228228
Three places own the security contract:
229229

230230
- **Encryptor**: the only path that generates age identities, derives recipients, and encrypts content. Plaintext never leaves this layer for any artefact going to disk or to the network.
231-
- **Sanitiser**: the only place that decides what is safe to encrypt. Never-sync paths and literal-secret detection are hard-coded rules, not opt-in policy. Loosening either requires a documented reason.
231+
- **Sanitiser**: the only place that decides what is safe to encrypt. Never-sync paths are hard-coded rules. Literal-secret detection is a **known-credential-format** guard (vendor key prefixes, AWS/GitHub/GitLab/Slack/Google tokens, age identities, PEM private keys, and JWTs in strict mode) — not a general secret scanner: a plain password or bespoke token with no recognised shape is not caught, so encryption, not the scan, is the real protection. The scan's job is to keep well-known credentials out of git history. Its breadth and the base64 redactor are tuned through the `[security]` config, resolved by `securityToPolicy`; the never-sync rules are not configurable.
232232
- **Tar bundler**: exists because some agent assets are directory-shaped. The tar is built in memory before encryption so an intermediate plaintext archive never lands on disk.
233233

234234
Private keys stay on disk in the local runtime directory (`~/.config/agentsync/key.txt` by default on Unix, with restrictive permissions). They are never committed and never logged.

docs/commands.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -346,18 +346,23 @@ agentsync config set security.allowSecretValues '["AKIA-not-a-real-key"]'
346346
| `sync.debounceMs` | integer 50–10000 | Daemon quiet-window before an auto-push. |
347347
| `sync.autoPush` | boolean | Whether the daemon auto-pushes on change. |
348348
| `claudePlugins.syncPlugins` | boolean | Record the Claude plugin reinstall manifest on push. |
349-
| `security.secretScan` | `standard`\|`strict`\|`off` | Push-time secret-scan mode (see note). |
350-
| `security.allowSecretValues` | string[] (JSON) | Literal values exempt from secret detection and base64 redaction (see note). |
351-
| `security.redactBase64Values` | boolean | Replace long base64-looking JSON values with a redaction placeholder (see note). |
352-
353-
> **`security.*` are recorded but not yet enforced.** This release stores the
354-
> policy in `agentsync.toml`; the push-time secret scanner starts honouring
355-
> `secretScan`, `allowSecretValues`, and `redactBase64Values` in a follow-up
356-
> change. Until then the scan runs with its built-in defaults regardless of
357-
> these values. Note `agentsync.toml` is committed in **plaintext** (only
358-
> artefacts are encrypted), so `allowSecretValues` is for exempting legitimate
349+
| `security.secretScan` | `standard`\|`strict`\|`off` | Push-time secret-scan mode. `standard` = built-in credential patterns; `strict` also flags JWTs; `off` disables the artefact-body scan. |
350+
| `security.allowSecretValues` | string[] (JSON) | Literal values exempt from secret detection and base64 redaction. |
351+
| `security.redactBase64Values` | boolean | When `true` (default), redact long base64-looking JSON values; set `false` if a config legitimately stores base64 that must round-trip. |
352+
353+
> **What the secret scan is — and is not.** It matches a fixed set of
354+
> high-precision **credential formats** (vendor API-key prefixes, AWS/GitHub/GitLab/Slack/Google
355+
> tokens, age identities, PEM private-key headers; `strict` adds JWTs). It is
356+
> **not** a general secret scanner — a plain password, a bespoke token, or a
357+
> connection string with no recognised shape passes through. Encryption is the
358+
> real protection; the scan only stops well-known credentials from entering git
359+
> history. `off` disables the artefact-body scan, but **skill-bundle interiors
360+
> are always scanned at `standard`** as a fail-safe. `agentsync.toml` itself is
361+
> committed in **plaintext**, so `allowSecretValues` is for exempting legitimate
359362
> high-entropy *non-secret* values — never paste a real credential there.
360-
> `config set` refuses a recognised credential in any other value.
363+
> `config set` refuses to store a recognised credential in any key other than
364+
> `security.allowSecretValues`.
365+
> See [Push aborts because secrets were detected](operations.md#push-aborts-because-secrets-were-detected).
361366
362367
**Outcome**: `list` and `get` are read-only. `set` validates the new value against the full config schema (so an out-of-range debounce or an invalid enum is rejected before anything is written), then — because `agentsync.toml` is shared across machines — reconciles fast-forward, commits, and pushes the change, exactly like `key add`.
363368

docs/operations.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ and behaviour reference.
310310

311311
### Push aborts because secrets were detected
312312

313-
The sanitiser found literal tokens or credentials in content that would otherwise be encrypted and committed. Sanitiser hits are intentionally a hard stop — they prevent the agent from leaking a secret into the vault, where it would persist even after subsequent pushes.
313+
The sanitiser found a literal token or credential in content that would otherwise be encrypted and committed. Sanitiser hits are intentionally a hard stop — a secret in git history persists even after later pushes and is one key-compromise away from retroactive exposure.
314314

315315
Fix:
316316

@@ -320,6 +320,27 @@ Fix:
320320

321321
Do not bypass this by editing the vault manually.
322322

323+
#### What the scan actually covers
324+
325+
Be precise about the guarantee. The scan is a **known-credential-format** detector, not a general secret scanner. It matches a fixed set of high-precision patterns:
326+
327+
- vendor API-key prefixes (`sk-ant-…`, `sk-proj-…`), GitHub (`ghp_…`, `github_pat_…`), GitLab (`glpat-…`), AWS access keys (`AKIA…`), Google (`AIza…`), Slack (`xox[abprs]-…`);
328+
- AgentSync's own age identity (`AGE-SECRET-KEY-1…`);
329+
- PEM private-key headers (`-----BEGIN … PRIVATE KEY-----`);
330+
- JWTs (`eyJ….eyJ….…`) — only when `security.secretScan = "strict"`.
331+
332+
What it does **not** catch: a plain password, a bespoke or internal API token, a database connection string, or any credential with no recognised shape. Those flow into the (encrypted) vault unflagged. **Encryption is the real protection** — the scan exists only to keep well-known credentials out of git history. Treat a clean push as "no recognised credential format found", not "no secrets present".
333+
334+
#### Tuning the scan
335+
336+
`agentsync config set security.secretScan <mode>`:
337+
338+
- `standard` (default) — the built-in credential patterns above, minus JWTs.
339+
- `strict` — adds JWT detection. Use when no legitimate JWT appears in your config.
340+
- `off` — disables the artefact-body scan. **Skill-bundle interiors are still scanned at `standard`** as a fail-safe, and encryption still applies.
341+
342+
`agentsync config set security.allowSecretValues '["<literal>"]'` exempts a specific value the scanner false-positives on (and exempts it from base64 redaction). `agentsync config set security.redactBase64Values false` stops AgentSync replacing long base64-looking JSON values with a placeholder, for configs that legitimately store such values. See [config](commands.md#config).
343+
323344
### Daemon is not running
324345

325346
Check, in order:

src/agents/claude/__tests__/sanitize.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,25 @@ describe("claude-sanitize", () => {
5757
};
5858
expect(out.hooks.PreToolUse[0]?.command).toBe(`${home}/runner`);
5959
});
60+
61+
// ─── Secret policy threading ──────────────────────────────────────────────
62+
// Proves the adapter honours the SecretPolicy it is handed, not just the
63+
// default — guards against an adapter silently dropping the policy argument.
64+
65+
test("sanitizeClaudeMcp redacts a secret value under the default policy", () => {
66+
const raw = JSON.stringify({ mcpServers: { x: { env: { TOKEN: `ghp_${"a".repeat(36)}` } } } });
67+
const out = JSON.parse(sanitizeClaudeMcp(raw, "").value) as {
68+
mcpServers: { x: { env: { TOKEN: string } } };
69+
};
70+
expect(out.mcpServers.x.env.TOKEN).toContain("REDACTED");
71+
});
72+
73+
test("sanitizeClaudeMcp leaves the secret unredacted when policy mode is off", () => {
74+
const token = `ghp_${"a".repeat(36)}`;
75+
const raw = JSON.stringify({ mcpServers: { x: { env: { TOKEN: token } } } });
76+
const out = JSON.parse(
77+
sanitizeClaudeMcp(raw, "", { mode: "off", allow: [], redactBase64: true }).value,
78+
) as { mcpServers: { x: { env: { TOKEN: string } } } };
79+
expect(out.mcpServers.x.env.TOKEN).toBe(token);
80+
});
6081
});

src/agents/claude/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
22
import { AgentPaths } from "../../config/paths";
33
import type { AgentSyncConfig } from "../../config/schema";
44
import { denormalizeFromVault } from "../../core/path-portability";
5+
import { securityToPolicy } from "../../core/sanitizer";
56
import {
67
type ApplyPlan,
78
defineFileArtifact,
@@ -28,6 +29,7 @@ export type ClaudeSnapshotResult = SnapshotResult;
2829
/** Collect Claude files that are safe to store in the encrypted vault. */
2930
export async function snapshotClaude(config: AgentSyncConfig): Promise<SnapshotResult> {
3031
const syncPlugins = config.claudePlugins?.syncPlugins ?? false;
32+
const policy = securityToPolicy(config.security);
3133
const artifacts: SnapshotArtifact[] = [];
3234
const warnings: string[] = [];
3335

@@ -40,7 +42,7 @@ export async function snapshotClaude(config: AgentSyncConfig): Promise<SnapshotR
4042

4143
const settingsJson = await readIfExists(AgentPaths.claude.settingsJson);
4244
if (settingsJson !== null) {
43-
const hooks = sanitizeClaudeHooks(settingsJson, homedir());
45+
const hooks = sanitizeClaudeHooks(settingsJson, homedir(), policy);
4446
artifacts.push(
4547
collect(hooks, AgentPaths.claude.settingsJson, "claude/settings.hooks.json.age"),
4648
);
@@ -49,7 +51,7 @@ export async function snapshotClaude(config: AgentSyncConfig): Promise<SnapshotR
4951

5052
const mcpJson = await readIfExists(AgentPaths.claude.mcpJson);
5153
if (mcpJson !== null) {
52-
const mcp = sanitizeClaudeMcp(mcpJson, homedir());
54+
const mcp = sanitizeClaudeMcp(mcpJson, homedir(), policy);
5355
artifacts.push(collect(mcp, AgentPaths.claude.mcpJson, "claude/claude.json.age"));
5456
warnings.push(...mcp.warnings);
5557
}

src/agents/claude/sanitize.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313

1414
import { homedir } from "node:os";
1515
import { normalizeForVault } from "../../core/path-portability";
16-
import { type RedactionResult, redactSecretLiterals } from "../../core/sanitizer";
16+
import {
17+
DEFAULT_SECRET_POLICY,
18+
type RedactionResult,
19+
redactSecretLiterals,
20+
type SecretPolicy,
21+
} from "../../core/sanitizer";
1722

1823
/**
1924
* Keep only Claude hook settings and redact any embedded literal secrets.
@@ -24,11 +29,12 @@ import { type RedactionResult, redactSecretLiterals } from "../../core/sanitizer
2429
export function sanitizeClaudeHooks(
2530
rawSettingsJson: string,
2631
home: string = homedir(),
32+
policy: SecretPolicy = DEFAULT_SECRET_POLICY,
2733
): RedactionResult<string> {
2834
const parsed = JSON.parse(rawSettingsJson) as Record<string, unknown>;
2935
const hooksOnly = { hooks: parsed.hooks ?? {} };
3036
const normalized = normalizeForVault(hooksOnly, home);
31-
const redacted = redactSecretLiterals(normalized, "hooks");
37+
const redacted = redactSecretLiterals(normalized, "hooks", policy);
3238
return {
3339
value: `${JSON.stringify(redacted.value, null, 2)}\n`,
3440
warnings: redacted.warnings,
@@ -42,11 +48,12 @@ export function sanitizeClaudeHooks(
4248
export function sanitizeClaudeMcp(
4349
rawClaudeJson: string,
4450
home: string = homedir(),
51+
policy: SecretPolicy = DEFAULT_SECRET_POLICY,
4552
): RedactionResult<string> {
4653
const parsed = JSON.parse(rawClaudeJson) as Record<string, unknown>;
4754
const mcpOnly = { mcpServers: parsed.mcpServers ?? {} };
4855
const normalized = normalizeForVault(mcpOnly, home);
49-
const redacted = redactSecretLiterals(normalized, "mcpServers");
56+
const redacted = redactSecretLiterals(normalized, "mcpServers", policy);
5057
return {
5158
value: `${JSON.stringify(redacted.value, null, 2)}\n`,
5259
warnings: redacted.warnings,

src/agents/codex/index.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import * as TOML from "@iarna/toml";
33
import { AgentPaths } from "../../config/paths";
44
import type { AgentSyncConfig } from "../../config/schema";
55
import { denormalizeFromVault, normalizeForVault } from "../../core/path-portability";
6-
import { type RedactionResult, redactSecretLiterals } from "../../core/sanitizer";
6+
import {
7+
DEFAULT_SECRET_POLICY,
8+
type RedactionResult,
9+
redactSecretLiterals,
10+
type SecretPolicy,
11+
securityToPolicy,
12+
} from "../../core/sanitizer";
713
import {
814
type ApplyPlan,
915
defineFileArtifact,
@@ -30,7 +36,11 @@ export type CodexSnapshotResult = SnapshotResult;
3036
* Using TOML parse → redact → stringify avoids the line-level regex approach which
3137
* misses multi-line values and nested tables.
3238
*/
33-
function sanitizeCodexConfig(raw: string, home: string = homedir()): RedactionResult<string> {
39+
function sanitizeCodexConfig(
40+
raw: string,
41+
home: string = homedir(),
42+
policy: SecretPolicy = DEFAULT_SECRET_POLICY,
43+
): RedactionResult<string> {
3444
const warnings: string[] = [];
3545
let parsed: TOML.JsonMap;
3646
try {
@@ -41,7 +51,7 @@ function sanitizeCodexConfig(raw: string, home: string = homedir()): RedactionRe
4151
}
4252

4353
const normalized = normalizeForVault(parsed as unknown, home);
44-
const redacted = redactSecretLiterals(normalized, "codex_config");
54+
const redacted = redactSecretLiterals(normalized, "codex_config", policy);
4555
warnings.push(...redacted.warnings);
4656
return {
4757
value: TOML.stringify(redacted.value as TOML.JsonMap),
@@ -50,7 +60,8 @@ function sanitizeCodexConfig(raw: string, home: string = homedir()): RedactionRe
5060
}
5161

5262
/** Collect Codex instructions, rules, and config that are safe to sync. */
53-
export async function snapshotCodex(_config?: AgentSyncConfig): Promise<SnapshotResult> {
63+
export async function snapshotCodex(config?: AgentSyncConfig): Promise<SnapshotResult> {
64+
const policy = securityToPolicy(config?.security);
5465
const artifacts: SnapshotArtifact[] = [];
5566
const warnings: string[] = [];
5667

@@ -69,7 +80,7 @@ export async function snapshotCodex(_config?: AgentSyncConfig): Promise<Snapshot
6980

7081
const configToml = await readIfExists(AgentPaths.codex.configToml);
7182
if (configToml !== null) {
72-
const sanitized = sanitizeCodexConfig(configToml, homedir());
83+
const sanitized = sanitizeCodexConfig(configToml, homedir(), policy);
7384
artifacts.push(collect(sanitized, AgentPaths.codex.configToml, "codex/config.toml.age"));
7485
warnings.push(...sanitized.warnings);
7586
}

src/agents/cursor/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type ParseError, parse as parseJsonc } from "jsonc-parser";
44
import { AgentPaths } from "../../config/paths";
55
import type { AgentSyncConfig } from "../../config/schema";
66
import { denormalizeStringFromVault, normalizeStringForVault } from "../../core/path-portability";
7-
import { sanitizeAndNormalizeJson } from "../../core/sanitizer";
7+
import { sanitizeAndNormalizeJson, securityToPolicy } from "../../core/sanitizer";
88
import {
99
type ApplyPlan,
1010
defineFileArtifact,
@@ -70,7 +70,8 @@ function validateCursorRuleName(ruleName: string): void {
7070
}
7171

7272
/** Collect Cursor rules, MCP config, and commands that are safe to sync. */
73-
export async function snapshotCursor(_config?: AgentSyncConfig): Promise<SnapshotResult> {
73+
export async function snapshotCursor(config?: AgentSyncConfig): Promise<SnapshotResult> {
74+
const policy = securityToPolicy(config?.security);
7475
const artifacts: SnapshotArtifact[] = [];
7576
const warnings: string[] = [];
7677

@@ -86,7 +87,7 @@ export async function snapshotCursor(_config?: AgentSyncConfig): Promise<Snapsho
8687

8788
const mcpRaw = await readIfExists(AgentPaths.cursor.mcpGlobal);
8889
if (mcpRaw !== null) {
89-
const sanitized = sanitizeAndNormalizeJson(mcpRaw, "cursor_mcp");
90+
const sanitized = sanitizeAndNormalizeJson(mcpRaw, "cursor_mcp", homedir(), policy);
9091
artifacts.push(collect(sanitized, AgentPaths.cursor.mcpGlobal, "cursor/mcp.json.age"));
9192
warnings.push(...sanitized.warnings);
9293
}

src/agents/vscode/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
22
import { AgentPaths } from "../../config/paths";
33
import type { AgentSyncConfig } from "../../config/schema";
44
import { denormalizeStringFromVault } from "../../core/path-portability";
5-
import { sanitizeAndNormalizeJson } from "../../core/sanitizer";
5+
import { sanitizeAndNormalizeJson, securityToPolicy } from "../../core/sanitizer";
66
import { type ApplyPlan, defineFileArtifact, makeApplyVault } from "../_apply";
77
import {
88
atomicWrite,
@@ -16,13 +16,14 @@ import {
1616
export type VsCodeSnapshotResult = SnapshotResult;
1717

1818
/** Collect the VS Code MCP configuration that AgentSync manages. */
19-
export async function snapshotVsCode(_config?: AgentSyncConfig): Promise<SnapshotResult> {
19+
export async function snapshotVsCode(config?: AgentSyncConfig): Promise<SnapshotResult> {
20+
const policy = securityToPolicy(config?.security);
2021
const artifacts: SnapshotArtifact[] = [];
2122
const warnings: string[] = [];
2223

2324
const mcpRaw = await readIfExists(AgentPaths.vscode.mcpJson);
2425
if (mcpRaw !== null) {
25-
const sanitized = sanitizeAndNormalizeJson(mcpRaw, "vscode_mcp");
26+
const sanitized = sanitizeAndNormalizeJson(mcpRaw, "vscode_mcp", homedir(), policy);
2627
const artifact = collect(sanitized, AgentPaths.vscode.mcpJson, "vscode/mcp.json.age");
2728
artifacts.push(artifact);
2829
warnings.push(...sanitized.warnings);

0 commit comments

Comments
 (0)