Skip to content
Merged
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
16 changes: 12 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,18 @@ bun run check:act # run CI workflow locally via nektos/act

## Conventions and gotchas

- **Encryption is non-negotiable**: every artifact written to the vault must
go through `src/core/encryptor.ts`. `src/core/sanitizer.ts` enforces
hard never-sync patterns and aborts the push when literal secrets are
detected — do not loosen these without a documented reason.
- **Encryption is non-negotiable; the secret policy is tiered**: every
artifact written to the vault goes through `src/core/encryptor.ts`.
`src/core/sanitizer.ts` enforces hard never-sync patterns plus a
**catastrophic tier** (`ALWAYS_BLOCK_PATTERNS` — the vault's own age key and
PEM private keys) that aborts the push in EVERY `secretScan` mode (`off` and
`redact` included) and is never exemptible via `allowSecretValues`. Ordinary
API tokens follow the mode: `standard`/`strict` abort; `redact` replaces them
in structured config with a `$AGENTSYNC_REDACTED_<FIELD>` placeholder and
pushes (a secret in prose still aborts); `off` waives them. On the apply side,
redact placeholders are reconciled by `mergePreservingSecrets`
(`src/core/secret-merge.ts`) — a placeholder never overwrites a real local
value. Do not loosen these without a documented reason.
- **Per-machine vault layout (v2)**: every artifact lives under
`machines/<name>/<agent>/…`, composed only through `machineVaultRoot` in
`src/config/paths.ts` (never hardcode the `machines/` segment). Each machine
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ Daemon installation paths per OS, log locations, and the configuration table liv
Three places own the security contract:

- **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.
- **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.
- **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. A **catastrophic tier** (the vault's own age key and PEM private keys) blocks the push in every mode and cannot be allow-listed. `secretScan = redact` swaps an ordinary token in structured config for a `$AGENTSYNC_REDACTED_<FIELD>` placeholder and pushes; on apply, `mergePreservingSecrets` keeps a real local value rather than overwrite it with the placeholder.
- **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.

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.
Expand Down
4 changes: 2 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ agentsync tui # explicit alias, same behaviour
| 3 Machines | The vault's `machines/<name>/` namespaces. Move with `↑`/`↓`; `enter` copies the selected machine's config to this machine (the same `performCopy` core as the CLI; never touches the vault). |
| 4 Migrate | From / To / Type form (To and Type are multi-select with sub-cursor). Preview is mandatory before Apply enables. |
| 5 Activity | Session-only ring buffer of TUI actions. |
| 6 Config | View and change vault config (agents enabled, `sync.*`, `claudePlugins.*`, `security.*`) with `↑`/`↓` to move, `space` to toggle a boolean, `←`/`→` to cycle an enum or adjust a number. Writes go through the same [`config`](#config) core (reconcile + commit + push). Also lists the recipients who can decrypt the vault, read-only. |
| 6 Config | View and change vault config (agents enabled, `sync.*`, `claudePlugins.*`, `security.*`) with `↑`/`↓` to move, `space` to toggle a boolean, `←`/`→` to cycle an enum or adjust a number. Cycling `security.secretScan` shows a one-line explainer of the selected mode; choosing `off` first prompts a `y`/`n` confirm because it pushes live secrets. Writes go through the same [`config`](#config) core (reconcile + commit + push). Also lists the recipients who can decrypt the vault, read-only. |

**Global keys** (any tab):

Expand Down Expand Up @@ -376,7 +376,7 @@ agentsync config set security.allowSecretValues '["AKIA-not-a-real-key"]'
| `sync.debounceMs` | integer 50–10000 | Daemon quiet-window before an auto-push. |
| `sync.autoPush` | boolean | Whether the daemon auto-pushes on change. |
| `claudePlugins.syncPlugins` | boolean | Record the Claude plugin reinstall manifest on push. |
| `security.secretScan` | `standard`\|`strict`\|`off` | Push-time secret-scan mode. `standard` = built-in credential patterns; `strict` also flags JWTs; `off` waives the ordinary API-token patterns (the catastrophic tier age key, PEM private keys — still blocks in every mode). |
| `security.secretScan` | `standard`\|`strict`\|`redact`\|`off` | Push-time secret-scan mode. `standard` = built-in credential patterns (abort on hit); `strict` also flags JWTs; `redact` replaces ordinary tokens in structured config with a `$AGENTSYNC_REDACTED_<FIELD>` placeholder and pushes (`copy` then preserves a real local value over the placeholder); `off` waives the ordinary patterns. The catastrophic tier (age key, PEM) still blocks in every mode. |
| `security.allowSecretValues` | string[] (JSON) | Literal values exempt from ordinary-token detection and base64 redaction. Catastrophic-tier values (age key, PEM private keys) are never exemptible. |
| `security.redactBase64Values` | boolean | When `true` (default), redact long base64-looking JSON values; set `false` if a config legitimately stores base64 that must round-trip. |

Expand Down
19 changes: 16 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,11 +345,24 @@ What it does **not** catch: a plain password, a bespoke or internal API token, a

`agentsync config set security.secretScan <mode>`:

- `standard` (default) — the built-in credential patterns above, minus JWTs.
- `standard` (default) — the built-in credential patterns above, minus JWTs. A literal token aborts the push.
- `strict` — adds JWT detection. Use when no legitimate JWT appears in your config.
- `off` — waives the ordinary API-token patterns; those values ride into the (encrypted) vault unflagged. The **catastrophic tier still blocks in every mode, `off` included**: the vault's own age key (`AGE-SECRET-KEY-1…`) and PEM private keys can never be pushed — no encryption makes it safe to commit the key that decrypts the vault itself. **Skill-bundle interiors are still scanned at `standard`** as a fail-safe.
- `redact` — instead of aborting, replace an ordinary API token inside a structured config file (`.claude.json`, `mcp.json`, Codex `config.toml`, hook settings) with a `$AGENTSYNC_REDACTED_<FIELD>` placeholder and push. A secret in **prose** (a markdown body, a skill README) has no structured field to replace, so it still aborts — remove it. See "The redact contract" below.
- `off` — waives the ordinary API-token patterns; those values ride into the (encrypted) vault unflagged. Encryption is then the *only* protection: every recipient and any lost device key can read them.

`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).
In **every** mode — `redact` and `off` included — the **catastrophic tier still blocks**: the vault's own age key (`AGE-SECRET-KEY-1…`) and PEM private keys can never be pushed, redacted, or allow-listed, because no encryption makes it safe to commit the key that decrypts the vault itself. **Skill-bundle interiors are always scanned at `standard`** as a fail-safe.

`agentsync config set security.allowSecretValues '["<literal>"]'` exempts a specific value the scanner false-positives on (and exempts it from base64 redaction) — for ordinary tokens only; a catastrophic-tier literal is refused even here. `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).

#### The redact contract

`redact` mode is a round trip, and the second half lives on the **apply** side (`agentsync copy`):

1. **Push** replaces the token with `$AGENTSYNC_REDACTED_<FIELD>` — a shell-env-var-shaped placeholder — and ships that. The real secret never enters the vault.
2. **Copy** onto another machine merges the incoming config: a placeholder **never overwrites a real local value**, and local-only entries (e.g. an MCP server not in the vault) are preserved. So a machine that already holds the key keeps it.
3. On a **fresh** machine with no local value, the placeholder lands in the config as-is. AgentSync does **not** expand it — replace `$AGENTSYNC_REDACTED_<FIELD>` with the real secret on that machine (paste the value, or point the field at your own secret manager / the agent's native `${VAR}` env syntax). The literal `$AGENTSYNC_REDACTED_…` is your signal that a value is required.

This is the right default when a vault has more than one recipient (a teammate's key, or another of your own devices): encryption alone lets every recipient read every secret, so keeping the token out of the vault entirely is the only way to scope it to the machine that owns it.

### Daemon is not running

Expand Down
22 changes: 21 additions & 1 deletion src/agents/__tests__/_utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTmpDir } from "../../test-helpers/fixtures";
import { atomicWrite, collect, readIfExists, setJsoncTopLevelKey } from "../_utils";
import {
atomicWrite,
collect,
getJsoncTopLevelKey,
parseJsoncObject,
readIfExists,
setJsoncTopLevelKey,
} from "../_utils";

// _utils helpers

Expand Down Expand Up @@ -143,4 +150,17 @@ describe("agents/_utils", () => {
const out = setJsoncTopLevelKey("{not valid json", "rules", "x");
expect(JSON.parse(out)).toEqual({ rules: "x" });
});

test("parseJsoncObject reads a valid object but rejects malformed input", () => {
expect(parseJsoncObject('{"a": 1, /* c */ "b": 2,}')).toEqual({ a: 1, b: 2 });
// jsonc-parser would partially recover this; we reject it so a corrupt local
// file is never used as a merge base.
expect(parseJsoncObject("{not valid json")).toBeUndefined();
expect(parseJsoncObject("[1,2,3]")).toBeUndefined(); // non-object root
});

test("getJsoncTopLevelKey reads a key but rejects malformed input", () => {
expect(getJsoncTopLevelKey('{"mcpServers": {"x": 1}}', "mcpServers")).toEqual({ x: 1 });
expect(getJsoncTopLevelKey("{not valid json", "mcpServers")).toBeUndefined();
});
});
33 changes: 33 additions & 0 deletions src/agents/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ export function setJsoncTopLevelKey(raw: string, key: string, value: unknown): s
return applyEdits(raw, edits);
}

/**
* Read a single top-level key from a JSONC document, tolerating comments and
* trailing commas. Returns undefined when the document is empty, malformed, a
* non-object root, or the key is absent — callers treat "no local value" the
* same as a missing file, so a best-effort parse is the right contract here.
*/
export function getJsoncTopLevelKey(raw: string, key: string): unknown {
// jsonc-parser is fault-tolerant: it returns a partially-recovered object on
// malformed input. Reject anything with parse errors (matching
// setJsoncTopLevelKey) so a corrupt local file is treated as "no value"
// rather than merged from a half-parsed tree.
const errors: ParseError[] = [];
const parsed = parse(raw, errors, { allowTrailingComma: true });
return errors.length === 0 && parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)[key]
: undefined;
}

/**
* Parse a whole JSONC document into a plain object, tolerating comments and
* trailing commas. Returns undefined for an empty, malformed, or non-object
* root — callers treat that as "no local value to merge against".
*/
export function parseJsoncObject(raw: string): Record<string, unknown> | undefined {
// Reject on parse errors (see getJsoncTopLevelKey): a half-parsed object must
// not become a merge base, or apply could drop or corrupt local config.
const errors: ParseError[] = [];
const parsed = parse(raw, errors, { allowTrailingComma: true });
return errors.length === 0 && parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
}

/**
* Build a `SnapshotArtifact` from a `RedactionResult<string>`.
* Using this helper keeps the artifact shape consistent across agents.
Expand Down
27 changes: 27 additions & 0 deletions src/agents/claude/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,33 @@ describe("apply* functions", () => {
expect((parsed.mcpServers as Record<string, unknown>).srv).toBeDefined();
});

test("applyClaudeMcp keeps a local secret when the vault ships a placeholder", async () => {
await writeFile(
testClaudePaths.mcpJson,
JSON.stringify({
mcpServers: {
foo: { command: "x", env: { TOKEN: "sk-real-local" } },
localOnly: { command: "z" },
},
}),
"utf8",
);
// `redact` mode ships a $AGENTSYNC_REDACTED_ placeholder for foo.env.TOKEN.
await claudeModule.applyClaudeMcp(
JSON.stringify({
mcpServers: { foo: { command: "x", env: { TOKEN: "$AGENTSYNC_REDACTED_TOKEN" } } },
}),
);
const parsed = JSON.parse(await Bun.file(testClaudePaths.mcpJson).text()) as Record<
string,
{ foo: { env: { TOKEN: string } }; localOnly?: unknown }
>;
const mcp = parsed.mcpServers;
expect(mcp.foo.env.TOKEN).toBe("sk-real-local"); // local secret preserved
expect(mcp.localOnly).toBeDefined(); // local-only server survives
expect(JSON.stringify(parsed)).not.toContain("AGENTSYNC_REDACTED");
});

test("applyClaudeCommand writes a command file", async () => {
await claudeModule.applyClaudeCommand("review.md", "# Code review command");
const content = await Bun.file(join(testClaudePaths.commandsDir, "review.md")).text();
Expand Down
11 changes: 9 additions & 2 deletions src/agents/claude/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AgentPaths } from "../../config/paths";
import type { AgentSyncConfig } from "../../config/schema";
import { denormalizeFromVault } from "../../core/path-portability";
import { securityToPolicy } from "../../core/sanitizer";
import { mergePreservingSecrets } from "../../core/secret-merge";
import {
type ApplyPlan,
defineFileArtifact,
Expand All @@ -14,6 +15,7 @@ import { collectMarkdownDir, collectSingleFile } from "../_snapshot";
import {
atomicWrite,
collect,
getJsoncTopLevelKey,
readIfExists,
type SnapshotArtifact,
type SnapshotResult,
Expand Down Expand Up @@ -155,10 +157,15 @@ export async function applyClaudeMcp(claudeJsonContent: string): Promise<void> {
const incoming = JSON.parse(claudeJsonContent) as Record<string, unknown>;
// ~/.claude.json is large and JSONC-tolerant. Edit `mcpServers` in place so
// the rest of Claude's config (and any trailing comma) is left untouched.
const mcpServers = denormalizeFromVault(incoming.mcpServers ?? {}, home);
const incomingMcp = denormalizeFromVault(incoming.mcpServers ?? {}, home);
// Merge onto the local mcpServers: a redacted placeholder (`redact` mode)
// must not overwrite a real local key, and local-only servers survive (copy
// is additive). With no local file this is just the incoming tree.
const existingMcp = existingRaw ? getJsoncTopLevelKey(existingRaw, "mcpServers") : undefined;
const { merged } = mergePreservingSecrets(existingMcp ?? {}, incomingMcp);
await atomicWrite(
AgentPaths.claude.mcpJson,
setJsoncTopLevelKey(existingRaw ?? "", "mcpServers", mcpServers),
setJsoncTopLevelKey(existingRaw ?? "", "mcpServers", merged),
);
}

Expand Down
17 changes: 17 additions & 0 deletions src/agents/codex/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,23 @@ describe("apply* functions", () => {
expect(content).toContain("local_only");
});

test("applyCodexConfig keeps a local secret when the vault ships a placeholder", async () => {
await writeFile(
testCodexPaths.configToml,
'model = "gpt-4"\n\n[mcp.foo]\napi_key = "sk-real-local-secret"\n',
"utf8",
);
// `redact` mode ships a $AGENTSYNC_REDACTED_ placeholder instead of the key.
await codexModule.applyCodexConfig(
'model = "o3"\n\n[mcp.foo]\napi_key = "$AGENTSYNC_REDACTED_API_KEY"\n',
);

const content = await Bun.file(testCodexPaths.configToml).text();
expect(content).toContain("o3"); // incoming non-secret wins
expect(content).toContain("sk-real-local-secret"); // local secret preserved
expect(content).not.toContain("AGENTSYNC_REDACTED"); // placeholder not written
});

test("applyCodexRule writes a rule file", async () => {
await codexModule.applyCodexRule("testing.md", "## Testing rules");
const content = await Bun.file(join(testCodexPaths.rulesDir, "testing.md")).text();
Expand Down
8 changes: 5 additions & 3 deletions src/agents/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type SecretPolicy,
securityToPolicy,
} from "../../core/sanitizer";
import { mergePreservingSecrets } from "../../core/secret-merge";
import {
type ApplyPlan,
defineFileArtifact,
Expand Down Expand Up @@ -144,9 +145,10 @@ export async function applyCodexConfig(content: string): Promise<void> {
return;
}

// Shallow-merge at top level: incoming keys win, local-only keys survive.
const merged: TOML.JsonMap = { ...existing, ...incoming };
await atomicWrite(AgentPaths.codex.configToml, TOML.stringify(merged));
// Deep, placeholder-aware merge: a redacted placeholder (`redact` mode) must
// not overwrite a real local value, and nested local-only keys survive.
const { merged } = mergePreservingSecrets(existing, incoming);
await atomicWrite(AgentPaths.codex.configToml, TOML.stringify(merged as TOML.JsonMap));
}

/** Restore one Codex rule markdown file from the vault. */
Expand Down
14 changes: 14 additions & 0 deletions src/agents/cursor/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,20 @@ describe("cursor apply functions", () => {
expect(await Bun.file(testCursorPaths.mcpGlobal).text()).toBe(content);
});

test("applyCursorMcp keeps a local secret when the vault ships a placeholder", async () => {
const { applyCursorMcp } = cursorModule;
await Bun.write(
testCursorPaths.mcpGlobal,
JSON.stringify({ mcpServers: { foo: { env: { KEY: "sk-real-local" } } } }),
);
await applyCursorMcp(
`${JSON.stringify({ mcpServers: { foo: { env: { KEY: "$AGENTSYNC_REDACTED_KEY" } } } }, null, 2)}\n`,
);
const written = await Bun.file(testCursorPaths.mcpGlobal).text();
expect(written).toContain("sk-real-local"); // local secret preserved
expect(written).not.toContain("AGENTSYNC_REDACTED");
});

test("applyCursorCommand writes named command file under commandsDir", async () => {
const { applyCursorCommand } = cursorModule;
await applyCursorCommand("my-cmd.md", "# My Cmd\nDo things.");
Expand Down
Loading
Loading