diff --git a/README.md b/README.md index 37499c7..5b0a318 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ The full quickstart, command reference, and architecture model live at the docum | `doctor` | Run environment, key, vault, and daemon diagnostics. | | `daemon` | Install and manage the background auto-sync daemon. | | `key` | Add, list, or remove recipients (revocation), or rotate the local machine key. | +| `config` | View or change vault config: agents enabled, sync behaviour, secret-handling policy. | | `skill` | Remove a skill from the vault explicitly. | | `migrate` | Translate configuration between agent formats locally. | | `destroy` | Wipe the local vault clone (default) or the remote vault contents via a normal commit. **Local agent files (`~/.claude`, `~/.cursor`, …) are never touched.** | diff --git a/docs/commands.md b/docs/commands.md index e35f62c..b7185c9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -47,6 +47,7 @@ When developing from source, replace the binary call with `bun run src/cli.ts`. | [`doctor`](#doctor) | Check the local environment before blaming sync logic. | | [`daemon`](#daemon) | Install, start, stop, and inspect the background daemon. | | [`key`](#key) | Add, list, or remove recipients, or rotate the current machine key. | +| [`config`](#config) | View or change vault config (agents, sync, security policy). | | [`skill`](#skill) | Remove a skill from the vault. | | [`plugin`](#plugin) | List or reinstall a machine's Claude plugins from its vault manifest. | | [`vault`](#vault) | Migrate an older vault to the current format (`vault upgrade`). | @@ -323,6 +324,49 @@ agentsync key rotate # new local identity, re-encrypt - `key remove` refuses to remove the only remaining recipient (a vault must stay decryptable) and refuses to remove the key of the machine you run it on (you cannot deauthorize yourself — run it from another machine to remove a lost one). - Recipient names are stable config keys and are visible in the vault repository. Use names that describe the machine clearly without leaking sensitive context. +## config + +**Why**: View or change the vault configuration in `agentsync.toml` without hand-editing it — which agents are enabled, daemon sync behaviour, and the secret-handling policy. + +**Usage**: + +```bash +agentsync config list # print every configurable key +agentsync config get sync.debounceMs # read one value +agentsync config set agents.vscode true # enable VS Code sync +agentsync config set security.secretScan strict # widen secret detection +agentsync config set security.allowSecretValues '["AKIA-not-a-real-key"]' +``` + +**Settable keys** (dotted paths under these sections): + +| Key | Type | Meaning | +|---|---|---| +| `agents.` | boolean | Whether that agent is snapshotted on push. | +| `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 (see note). | +| `security.allowSecretValues` | string[] (JSON) | Literal values exempt from secret detection and base64 redaction (see note). | +| `security.redactBase64Values` | boolean | Replace long base64-looking JSON values with a redaction placeholder (see note). | + +> **`security.*` are recorded but not yet enforced.** This release stores the +> policy in `agentsync.toml`; the push-time secret scanner starts honouring +> `secretScan`, `allowSecretValues`, and `redactBase64Values` in a follow-up +> change. Until then the scan runs with its built-in defaults regardless of +> these values. Note `agentsync.toml` is committed in **plaintext** (only +> artefacts are encrypted), so `allowSecretValues` is for exempting legitimate +> high-entropy *non-secret* values — never paste a real credential there. +> `config set` refuses a recognised credential in any other value. + +**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`. + +**Caveats**: + +- `version`, `recipients`, and `remote` are **not** settable here. Recipients are managed by [`key`](#key); the remote is fixed at [`init`](#init); the format version is the old-binary guard. +- A value is parsed as JSON first (`true`, `500`, `["x"]`), falling back to a plain string for bare words (`strict`). Quote a JSON array in your shell. +- `set` reconciles against the remote first, so it fails closed on diverged history like every other vault-writing command. + ## skill **Why**: Remove a skill from the vault without affecting the rest of the snapshot. This is AgentSync's only explicit, non-additive operation. diff --git a/src/cli.ts b/src/cli.ts index 3b1eb3b..35191d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun import { defineCommand, runMain } from "citty"; +import { configCommand } from "./commands/config"; import { copyCommand } from "./commands/copy"; import { daemonCommand } from "./commands/daemon"; import { destroyCommand } from "./commands/destroy"; @@ -30,6 +31,7 @@ const main = defineCommand({ doctor: doctorCommand, daemon: daemonCommand, key: keyCommand, + config: configCommand, migrate: migrateCommand, skill: skillCommand, plugin: pluginCommand, diff --git a/src/commands/__tests__/config.test.ts b/src/commands/__tests__/config.test.ts new file mode 100644 index 0000000..db14964 --- /dev/null +++ b/src/commands/__tests__/config.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for the `agentsync config` verbs against real git fixtures (no git + * mocking), mirroring key.test.ts. Exercises list/get/set, the settable-prefix + * guard, unknown-key rejection, schema-backed value validation, and scalar + * type coercion. + */ +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { loadConfig, resolveConfigPath } from "../../config/loader"; +import { + createBareRepo, + createMachineFixture, + createTmpDir, + runGit, + seedVaultRepo, + type TestMachineFixture, +} from "../../test-helpers/fixtures"; + +{ + const require = createRequire(import.meta.url); + // biome-ignore lint/style/useNodejsImportProtocol: deliberate alias to bypass mock cache + const realFsPromises = require("fs/promises") as typeof import("node:fs/promises"); + mock.module("node:fs/promises", () => ({ ...realFsPromises, default: realFsPromises })); +} + +mock.module("@clack/prompts", () => ({ + intro: () => {}, + outro: () => {}, + log: { success: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + note: () => {}, + spinner: () => ({ start: () => {}, stop: () => {}, message: () => {} }), +})); + +type ConfigMod = typeof import("../config"); +let configMod: ConfigMod; + +const RUNTIME_ENV_KEYS = ["AGENTSYNC_VAULT_DIR", "AGENTSYNC_KEY_PATH", "AGENTSYNC_MACHINE"]; + +afterAll(() => { + mock.restore(); +}); + +describe("config command", () => { + let tmpDir: string; + let machine: TestMachineFixture; + let bare: string; + const savedEnv: Record = {}; + + beforeEach(async () => { + configMod = await import("../config"); + tmpDir = await createTmpDir(); + machine = await createMachineFixture(tmpDir, "config-test"); + bare = await createBareRepo(tmpDir); + seedVaultRepo({ machine, bareRepoPath: bare }); + for (const key of RUNTIME_ENV_KEYS) savedEnv[key] = process.env[key]; + process.env.AGENTSYNC_VAULT_DIR = machine.vaultDir; + process.env.AGENTSYNC_KEY_PATH = machine.keyPath; + process.env.AGENTSYNC_MACHINE = machine.machineName; + process.exitCode = 0; + }); + + function restore(): Promise { + for (const key of RUNTIME_ENV_KEYS) { + const value = savedEnv[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + return rm(tmpDir, { recursive: true, force: true }); + } + + // afterEach guarantees env + tmpdir cleanup even when an assertion throws + // mid-test, so a failing test cannot leak dirty state into the next one. + afterEach(restore); + + test("performConfigList returns settable keys and excludes version + recipients", async () => { + const entries = await configMod.performConfigList(); + const keys = entries.map((e) => e.key); + expect(keys).toContain("agents.claude"); + expect(keys).toContain("sync.debounceMs"); + expect(keys).toContain("security.secretScan"); + expect(keys.some((k) => k === "version")).toBe(false); + expect(keys.some((k) => k.startsWith("recipients"))).toBe(false); + }); + + test("performConfigGet reads a value and reports unknown keys", async () => { + const found = await configMod.performConfigGet("security.secretScan"); + expect(found).toEqual({ status: "found", key: "security.secretScan", value: "standard" }); + const missing = await configMod.performConfigGet("agents.nope"); + expect(missing.status).toBe("unknown-key"); + }); + + test("performConfigSet changes a boolean and persists it to the vault config", async () => { + const result = await configMod.performConfigSet("agents.vscode", "true"); + expect(result.status).toBe("success"); + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.agents.vscode).toBe(true); + }); + + test("performConfigSet coerces a numeric string and an enum word", async () => { + const num = await configMod.performConfigSet("sync.debounceMs", "500"); + expect(num.status).toBe("success"); + if (num.status === "success") expect(num.newValue).toBe(500); + + const enumSet = await configMod.performConfigSet("security.secretScan", "strict"); + expect(enumSet.status).toBe("success"); + + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.sync.debounceMs).toBe(500); + expect(config.security.secretScan).toBe("strict"); + }); + + test("performConfigSet sets an array value from JSON", async () => { + const result = await configMod.performConfigSet( + "security.allowSecretValues", + '["AKIAEXAMPLE","ghp_example"]', + ); + expect(result.status).toBe("success"); + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.security.allowSecretValues).toEqual(["AKIAEXAMPLE", "ghp_example"]); + }); + + test("performConfigSet refuses protected sections", async () => { + for (const key of ["version", "recipients.config-test", "remote.url"]) { + const result = await configMod.performConfigSet(key, "x"); + expect(result.status).toBe("not-settable"); + } + }); + + test("performConfigSet rejects an unknown key under a settable section", async () => { + const result = await configMod.performConfigSet("agents.cluade", "true"); + expect(result.status).toBe("unknown-key"); + }); + + test("performConfigSet rejects a value the schema forbids", async () => { + const tooSmall = await configMod.performConfigSet("sync.debounceMs", "5"); + expect(tooSmall.status).toBe("invalid-value"); + + const badEnum = await configMod.performConfigSet("security.secretScan", "loud"); + expect(badEnum.status).toBe("invalid-value"); + + // The vault config is unchanged after rejected sets. + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.sync.debounceMs).toBe(300); + expect(config.security.secretScan).toBe("standard"); + }); + + test("performConfigSet coerces a false boolean", async () => { + const result = await configMod.performConfigSet("sync.autoPush", "false"); + expect(result.status).toBe("success"); + if (result.status === "success") expect(result.newValue).toBe(false); + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.sync.autoPush).toBe(false); + }); + + test("performConfigSet refuses a prototype-pollution key and leaves Object.prototype intact", async () => { + const result = await configMod.performConfigSet( + "security.__proto__.toLocaleString", + '"polluted"', + ); + // Rejected before any write: the prototype-walk segment is not an own key. + expect(result.status).toBe("unknown-key"); + // The global prototype is untouched. + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.prototype.toLocaleString).toBeInstanceOf(Function); + }); + + test("performConfigSet pushes the change to the remote vault", async () => { + const result = await configMod.performConfigSet("sync.debounceMs", "750"); + expect(result.status).toBe("success"); + // Inspect the bare remote directly — the change must land there, not just + // in the local working copy, because agentsync.toml is shared across machines. + const onRemote = runGit(["show", "HEAD:agentsync.toml"], bare); + expect(onRemote).toContain("debounceMs = 750"); + }); + + test("performConfigSet fails closed on diverged history", async () => { + // Advance the remote independently from a second clone. + const otherClone = join(tmpDir, "other-clone"); + runGit(["clone", bare, otherClone]); + runGit(["config", "user.email", "t@t.local"], otherClone); + runGit(["config", "user.name", "t"], otherClone); + writeFileSync(join(otherClone, "remote-extra.txt"), "remote\n", "utf8"); + runGit(["add", "."], otherClone); + runGit(["commit", "-m", "remote advance"], otherClone); + runGit(["push", "origin", "main"], otherClone); + + // Advance the local vault on a divergent commit. + writeFileSync(join(machine.vaultDir, "local-extra.txt"), "local\n", "utf8"); + runGit(["add", "."], machine.vaultDir); + runGit(["commit", "-m", "local advance"], machine.vaultDir); + + const result = await configMod.performConfigSet("agents.vscode", "true"); + expect(result.status).toBe("failed"); + }); + + test("performConfigSet refuses a literal secret pasted into a config value", async () => { + // A GitHub classic PAT shape pasted into the wrong field. agentsync.toml is + // committed in plaintext, so this must be rejected before any write. + const token = `ghp_${"a".repeat(36)}`; + const result = await configMod.performConfigSet("agents.vscode", token); + expect(result.status).toBe("invalid-value"); + if (result.status === "invalid-value") expect(result.error).toMatch(/secret/i); + }); + + test("performConfigSet allows secret-shaped values in the allowlist (its purpose)", async () => { + const token = `ghp_${"b".repeat(36)}`; + const result = await configMod.performConfigSet("security.allowSecretValues", `["${token}"]`); + expect(result.status).toBe("success"); + const config = await loadConfig(resolveConfigPath(machine.vaultDir)); + expect(config.security.allowSecretValues).toEqual([token]); + }); +}); diff --git a/src/commands/__tests__/integration.test.ts b/src/commands/__tests__/integration.test.ts index 204979c..c4ddeb9 100644 --- a/src/commands/__tests__/integration.test.ts +++ b/src/commands/__tests__/integration.test.ts @@ -457,6 +457,7 @@ describe("integration", () => { autoPush: true, }, claudePlugins: { syncPlugins: false }, + security: { secretScan: "standard", allowSecretValues: [], redactBase64Values: true }, }); writeFileSync(join(machineB.vaultDir, ".gitignore"), "*.tmp\n", "utf8"); runGit(["init"], machineB.vaultDir); @@ -643,6 +644,7 @@ describe("integration", () => { autoPush: true, }, claudePlugins: { syncPlugins: false }, + security: { secretScan: "standard", allowSecretValues: [], redactBase64Values: true }, }); writeFileSync(join(machineB.vaultDir, ".gitignore"), "*.tmp\n", "utf8"); runGit(["init"], machineB.vaultDir); diff --git a/src/commands/config.ts b/src/commands/config.ts new file mode 100644 index 0000000..503ce4c --- /dev/null +++ b/src/commands/config.ts @@ -0,0 +1,298 @@ +import { log } from "@clack/prompts"; +import { defineCommand } from "citty"; +import { formatConfigError, loadConfig, resolveConfigPath, writeConfig } from "../config/loader"; +import { AgentSyncConfigSchema } from "../config/schema"; +import { GitClient } from "../core/git"; +import { scanForSecrets } from "../core/sanitizer"; +import { loadVaultConfigOrExit, resolveRuntimeContext } from "./shared"; + +// Sections a user may change through `config set`. `version`, `recipients`, and +// `remote` are deliberately excluded: version is the format guard, recipients +// are managed by `key add`/`key remove`, and changing the remote is `init`'s +// job. Everything settable lives under one of these object sections. +const SETTABLE_PREFIXES = ["agents.", "sync.", "claudePlugins.", "security."] as const; + +// The one settable key whose values are deliberately secret-shaped: it is the +// allowlist of literals to exempt from the secret scan, so scanning it would +// defeat its purpose. +const SECRET_EXEMPT_KEY = "security.allowSecretValues"; + +type Json = Record; + +/** + * Read a dotted path (`a.b.c`) from a nested object; undefined when any segment + * is missing. Uses own-property checks only (never the `in` operator) so an + * inherited name like `constructor` or `__proto__` reads as absent — that is + * what makes a prototype-walk key (`security.__proto__.x`) fail the + * existence guard in `performConfigSet` instead of reaching `setByPath`. + */ +function getByPath(obj: Json, path: string): unknown { + return path.split(".").reduce((acc, key) => { + if (acc && typeof acc === "object" && Object.hasOwn(acc as object, key)) { + return (acc as Json)[key]; + } + return undefined; + }, obj); +} + +/** + * Set a dotted path on a nested object, creating intermediate objects as needed. + * Every assignment is preceded by an inline literal guard against the three + * prototype-polluting segment names — assigning through `__proto__`, + * `constructor`, or `prototype` would reach `Object.prototype` and corrupt + * every object in the process (CWE-1321). + */ +function setByPath(obj: Json, path: string, value: unknown): void { + const keys = path.split("."); + let cursor = obj; + for (let i = 0; i < keys.length; i++) { + const key = keys[i] as string; + if (key === "__proto__" || key === "constructor" || key === "prototype") { + throw new Error(`Refusing to set unsafe config key segment '${key}'`); + } + if (i === keys.length - 1) { + cursor[key] = value; + return; + } + if (!Object.hasOwn(cursor, key) || typeof cursor[key] !== "object" || cursor[key] === null) { + cursor[key] = {}; + } + cursor = cursor[key] as Json; + } +} + +/** Flatten a config object to dotted `key`→`value` leaf pairs (arrays kept whole). */ +function flatten(obj: Json, prefix = ""): Array<[string, unknown]> { + const out: Array<[string, unknown]> = []; + for (const [key, value] of Object.entries(obj)) { + const dotted = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === "object" && !Array.isArray(value)) { + out.push(...flatten(value as Json, dotted)); + } else { + out.push([dotted, value]); + } + } + return out; +} + +/** + * Coerce a raw CLI string to a typed value. JSON.parse handles numbers, + * booleans, and arrays (`500`, `true`, `["a"]`); a bare word that is not valid + * JSON (e.g. `strict`) falls through as a plain string. The Zod schema is the + * real validator — this only picks the right primitive type. + */ +function parseScalar(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +/** One row for `performConfigList`. */ +export interface ConfigEntry { + key: string; + value: unknown; +} + +/** Result of `performConfigGet`. */ +export type ConfigGetResult = + | { status: "found"; key: string; value: unknown } + | { status: "unknown-key"; key: string }; + +/** Result of `performConfigSet`. */ +export type ConfigSetResult = + | { status: "success"; key: string; oldValue: unknown; newValue: unknown } + | { status: "not-settable"; key: string } + | { status: "unknown-key"; key: string } + | { status: "invalid-value"; key: string; error: string } + | { status: "failed"; error: string }; + +/** List every config leaf except recipients (use `agentsync key list` for those). */ +export async function performConfigList(): Promise { + const runtime = await resolveRuntimeContext(); + const config = await loadVaultConfigOrExit(runtime.vaultDir); + return flatten(config as unknown as Json) + .filter(([key]) => key !== "version" && !key.startsWith("recipients")) + .map(([key, value]) => ({ key, value })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +/** Read a single dotted config key. Read-only. */ +export async function performConfigGet(key: string): Promise { + const runtime = await resolveRuntimeContext(); + const config = await loadVaultConfigOrExit(runtime.vaultDir); + const value = getByPath(config as unknown as Json, key); + if (value === undefined) { + return { status: "unknown-key", key }; + } + return { status: "found", key, value }; +} + +/** + * Change a single dotted config key in the vault. Because `agentsync.toml` is + * shared across machines, this reconciles fast-forward, validates the mutated + * config against the full schema (so every constraint — debounce range, enum + * values, boolean types — is enforced without duplication), then commits and + * pushes like `key add`. + */ +export async function performConfigSet(key: string, rawValue: string): Promise { + if (!SETTABLE_PREFIXES.some((prefix) => key.startsWith(prefix))) { + return { status: "not-settable", key }; + } + + const runtime = await resolveRuntimeContext(); + const configPath = resolveConfigPath(runtime.vaultDir); + const config = await loadVaultConfigOrExit(runtime.vaultDir); + + try { + const git = new GitClient(runtime.vaultDir); + const reconciliation = await git.reconcileWithRemote({ + remote: "origin", + branch: config.remote.branch, + allowMissingRemote: true, + }); + const refreshed = await loadConfig(configPath); + + // The key must already exist post-load (defaults are materialised), so a + // typo like `agents.cluade` is rejected here instead of being silently + // stripped by the schema's unknown-key removal. + const oldValue = getByPath(refreshed as unknown as Json, key); + if (oldValue === undefined) { + return { status: "unknown-key", key }; + } + + // agentsync.toml is committed in PLAINTEXT (only artifacts are encrypted), + // so refuse to write a literal credential into it — e.g. a token pasted + // into the wrong field. The allowlist key is exempt by construction. + if (key !== SECRET_EXEMPT_KEY) { + const leaks = scanForSecrets(rawValue, key); + if (leaks.length > 0) { + return { + status: "invalid-value", + key, + error: `Refusing to store a literal secret in plaintext config (${leaks.join("; ")}).`, + }; + } + } + + const next = structuredClone(refreshed); + setByPath(next as unknown as Json, key, parseScalar(rawValue)); + + const validated = AgentSyncConfigSchema.safeParse(next); + if (!validated.success) { + return { + status: "invalid-value", + key, + error: formatConfigError(validated.error, configPath), + }; + } + + await writeConfig(configPath, validated.data); + await git.addAll(); + const committed = await git.commit({ message: `config: set ${key}` }); + if (committed) { + await git.push( + "origin", + validated.data.remote.branch, + reconciliation.status === "remote-missing" ? ["--set-upstream"] : [], + ); + } + + return { + status: "success", + key, + oldValue, + newValue: getByPath(validated.data as unknown as Json, key), + }; + } catch (err) { + return { status: "failed", error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Render a config value for display: strings as-is, everything else as JSON. */ +function formatValue(value: unknown): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +/** View or change vault configuration (agents, sync options, security policy). */ +export const configCommand = defineCommand({ + meta: { + name: "config", + description: "View or change vault configuration (agents, sync, security)", + }, + subCommands: { + list: defineCommand({ + meta: { description: "Print every configurable key and its current value" }, + async run() { + const entries = await performConfigList(); + for (const entry of entries) { + log.info(`${entry.key} = ${formatValue(entry.value)}`); + } + log.info( + `${entries.length} setting(s). Change one with \`agentsync config set \`.`, + ); + }, + }), + + get: defineCommand({ + meta: { description: "Print one config value by dotted key (e.g. sync.debounceMs)" }, + args: { + key: { type: "positional", required: true, description: "Dotted config key" }, + }, + async run({ args }) { + const result = await performConfigGet(String(args.key)); + if (result.status === "unknown-key") { + log.error(`Unknown config key: ${result.key}. Run \`agentsync config list\`.`); + process.exitCode = 1; + return; + } + log.info(`${result.key} = ${formatValue(result.value)}`); + }, + }), + + set: defineCommand({ + meta: { description: "Change one config value and push it to the vault" }, + args: { + key: { + type: "positional", + required: true, + description: "Dotted config key (e.g. agents.vscode)", + }, + value: { + type: "positional", + required: true, + description: "New value (true/false, a number, a word, or JSON)", + }, + }, + async run({ args }) { + const result = await performConfigSet(String(args.key), String(args.value)); + switch (result.status) { + case "success": + log.success( + `Set ${result.key} = ${formatValue(result.newValue)} (was ${formatValue(result.oldValue)}).`, + ); + return; + case "not-settable": + log.error( + `'${result.key}' is not settable here. Settable sections: ${SETTABLE_PREFIXES.map((p) => p.slice(0, -1)).join(", ")}. Use \`key\`/\`init\` for recipients and remote.`, + ); + process.exitCode = 1; + return; + case "unknown-key": + log.error(`Unknown config key: ${result.key}. Run \`agentsync config list\`.`); + process.exitCode = 1; + return; + case "invalid-value": + log.error(result.error); + process.exitCode = 1; + return; + case "failed": + log.error(result.error); + process.exitCode = 1; + return; + } + }, + }), + }, +}); diff --git a/src/commands/init.ts b/src/commands/init.ts index a62ec5e..dacc073 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -27,6 +27,12 @@ const DEFAULT_SYNC = { autoPush: true, }; +const DEFAULT_SECURITY = { + secretScan: "standard" as const, + allowSecretValues: [] as string[], + redactBase64Values: true, +}; + /** Discriminated result of a `performInit` invocation — lets non-CLI callers * (e.g. the TUI init wizard) react to outcomes without parsing log output. */ export type InitResult = @@ -189,6 +195,7 @@ export async function performInit(options: InitOptions): Promise { }, sync: existing?.sync ?? DEFAULT_SYNC, claudePlugins: existing?.claudePlugins ?? { syncPlugins: false }, + security: existing?.security ?? DEFAULT_SECURITY, }); // Pin the machine name to local state so a later hostname change cannot diff --git a/src/config/__tests__/schema.test.ts b/src/config/__tests__/schema.test.ts index 863b84d..1476b17 100644 --- a/src/config/__tests__/schema.test.ts +++ b/src/config/__tests__/schema.test.ts @@ -42,6 +42,35 @@ describe("AgentSyncConfigSchema", () => { expect(parsed.claudePlugins.syncPlugins).toBe(false); }); + test("defaults the whole security section when absent — back-compat without a version bump", () => { + // VALID_BASE has no [security], mirroring an agentsync.toml written before + // the section existed. It must still load, with safe defaults applied. + const parsed = AgentSyncConfigSchema.parse(VALID_BASE); + expect(parsed.security).toEqual({ + secretScan: "standard", + allowSecretValues: [], + redactBase64Values: true, + }); + }); + + test("fills inner security defaults when the section is partial", () => { + const parsed = AgentSyncConfigSchema.parse({ + ...VALID_BASE, + security: { secretScan: "strict" }, + }); + expect(parsed.security.secretScan).toBe("strict"); + expect(parsed.security.allowSecretValues).toEqual([]); + expect(parsed.security.redactBase64Values).toBe(true); + }); + + test("rejects an invalid security.secretScan value", () => { + const result = AgentSyncConfigSchema.safeParse({ + ...VALID_BASE, + security: { secretScan: "loud" }, + }); + expect(result.success).toBe(false); + }); + test("maps a legacy claudePlugins.syncMarketplace key to syncPlugins", () => { // Back-compat: existing v2 agentsync.toml files predate the rename and must // keep loading without a manual edit. diff --git a/src/config/schema.ts b/src/config/schema.ts index 9adc8b3..8edb74b 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -86,6 +86,31 @@ export const AgentSyncConfigSchema = z.object({ }) .default({ syncPlugins: false }), ), + // Secret-handling policy. Optional with safe defaults so existing + // agentsync.toml files validate unchanged. The schema is not `.strict()`, so + // an older binary that predates this section ignores it on load rather than + // failing — adding the section needs no vault version bump. + // + // NOTE: this section is the configuration surface only. The push-time secret + // scanner reads these fields in a follow-up change; until then the values are + // recorded but not yet enforced (the scan runs with its built-in defaults). + security: z + .object({ + // How the push-time secret scan behaves: + // standard — the built-in high-precision credential patterns (default) + // strict — standard plus generic PEM private-key and JWT detection + // off — disable the embedded-secret scan (encryption still applies) + secretScan: z.enum(["standard", "strict", "off"]).default("standard"), + // Literal values to exempt from secret detection AND base64 redaction. + // The escape hatch for a legitimate high-entropy config value that the + // scanner/redactor would otherwise flag or silently replace. + allowSecretValues: z.array(z.string()).default([]), + // When true (default), a whole JSON string value that looks like base64 + // (40+ chars) is replaced with a redaction placeholder. Set false when a + // config legitimately stores long base64 values that must round-trip. + redactBase64Values: z.boolean().default(true), + }) + .default({ secretScan: "standard", allowSecretValues: [], redactBase64Values: true }), }); /** Normalized runtime shape derived from the validated config schema. */ diff --git a/src/test-helpers/fixtures.ts b/src/test-helpers/fixtures.ts index 6041447..c360be5 100644 --- a/src/test-helpers/fixtures.ts +++ b/src/test-helpers/fixtures.ts @@ -28,6 +28,7 @@ export function createTestAgentSyncConfig( remote: { url: "test://vault", branch: "main" }, sync: { debounceMs: 300, autoPush: true }, claudePlugins: { syncPlugins: false }, + security: { secretScan: "standard", allowSecretValues: [], redactBase64Values: true }, ...overrides, }; }