agent-trellis 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,21 @@ export interface McpPlanResult {
45
45
  desired: DesiredMcpEntry[];
46
46
  conflicts: McpConflict[];
47
47
  }
48
+ /** Which field on `McpServerDef` a literal-secret match came from —
49
+ * `migrate`'s extraction path (trellis-migrate-extract-static-env-secrets
50
+ * design.md D1) needs this to decide whether a natural variable name
51
+ * exists to extract to: only `staticEnv` has one (its own dict key).
52
+ * A match in any other field still just refuses, unchanged. */
53
+ export type LiteralSecretField = "command" | "url" | "args" | "headers" | "staticEnv";
54
+ export interface LiteralSecretMatch {
55
+ label: string;
56
+ field: LiteralSecretField;
57
+ /** The dict key the match came from, only set for `staticEnv`/`headers`
58
+ * (the only two `Record<string, string>` fields) — `migrate`'s
59
+ * extraction path uses this as the variable name to extract to when
60
+ * `field === "staticEnv"` (its own key is already a natural name). */
61
+ key?: string;
62
+ }
48
63
  /**
49
64
  * Exported so `migrate`'s own read path (`src/commands/migrate.ts`) can
50
65
  * apply the identical check on the way *into* canonical, not just on the
@@ -59,6 +74,10 @@ export interface McpPlanResult {
59
74
  * read, diffed, and committed as plain text. One shared implementation,
60
75
  * not two that can drift, for the same reason `mcpConnect.ts`/
61
76
  * `mcpToolRegistry.ts` are shared between pi-bridge and the gateway.
77
+ *
78
+ * Scanning order is `command, url, args, headers, staticEnv` — a def
79
+ * matching in more than one field returns whichever is found first, same
80
+ * as before this function reported a field at all.
62
81
  */
63
- export declare function findLiteralSecret(def: McpServerDef): string | undefined;
82
+ export declare function findLiteralSecret(def: McpServerDef): LiteralSecretMatch | undefined;
64
83
  export declare function resolveMcpPlan(agentId: AgentId, mcp: McpConfig, managedAgents: readonly AgentId[], policy: SecretsPolicy): McpPlanResult;
@@ -71,13 +71,23 @@ const DANGEROUS_LITERAL_PATTERNS = [
71
71
  * read, diffed, and committed as plain text. One shared implementation,
72
72
  * not two that can drift, for the same reason `mcpConnect.ts`/
73
73
  * `mcpToolRegistry.ts` are shared between pi-bridge and the gateway.
74
+ *
75
+ * Scanning order is `command, url, args, headers, staticEnv` — a def
76
+ * matching in more than one field returns whichever is found first, same
77
+ * as before this function reported a field at all.
74
78
  */
75
79
  export function findLiteralSecret(def) {
76
- const candidates = [def.command, def.url, ...(def.args ?? []), ...Object.values(def.headers ?? {}), ...Object.values(def.staticEnv ?? {})].filter((v) => typeof v === "string");
77
- for (const candidate of candidates) {
80
+ const candidates = [
81
+ ...(def.command !== undefined ? [{ value: def.command, field: "command" }] : []),
82
+ ...(def.url !== undefined ? [{ value: def.url, field: "url" }] : []),
83
+ ...(def.args ?? []).map((value) => ({ value, field: "args" })),
84
+ ...Object.entries(def.headers ?? {}).map(([key, value]) => ({ value, field: "headers", key })),
85
+ ...Object.entries(def.staticEnv ?? {}).map(([key, value]) => ({ value, field: "staticEnv", key })),
86
+ ];
87
+ for (const { value, field, key } of candidates) {
78
88
  for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
79
- if (pattern.test(candidate)) {
80
- return label;
89
+ if (pattern.test(value)) {
90
+ return { label, field, key };
81
91
  }
82
92
  }
83
93
  }
@@ -149,11 +159,23 @@ export function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
149
159
  conflicts.push({ name, message: collisionMessage(name, agentId), remediation: collisionRemediation(name) });
150
160
  continue;
151
161
  }
152
- const secretLabel = findLiteralSecret(def);
153
- if (secretLabel) {
162
+ // Only `staticEnv` is refused here: it's the one shape a real
163
+ // credential is meant to leave via `env`/`env_vars`, proven to
164
+ // resolve back to a real value for every consumer (pi-bridge,
165
+ // claude-code, codex, kiro) — a literal surviving there means
166
+ // something bypassed `migrate`'s own extraction (a hand-edited
167
+ // servers.yaml, most likely) and must still be refused outright. A
168
+ // match in `command`/`url`/`args`/`headers` is accepted and written
169
+ // as ordinary literal config instead (trellis-migrate-extract-static-env-secrets
170
+ // design.md D11) — none of those fields has a `${VAR}` resolution
171
+ // mechanism proven across every consumer, so a fake reference there
172
+ // would be strictly worse than the literal it replaced; `secrets
173
+ // audit` keeps flagging canonical itself so this isn't silent.
174
+ const secretMatch = findLiteralSecret(def);
175
+ if (secretMatch?.field === "staticEnv") {
154
176
  conflicts.push({
155
177
  name,
156
- message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) — configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
178
+ message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretMatch.label}) — configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
157
179
  remediation: `replace the literal value in servers.yaml with a \`\${VAR_NAME}\` reference and put the real value wherever secrets.policy.yaml resolves it from`,
158
180
  });
159
181
  continue;
package/dist/cli.js CHANGED
@@ -160,6 +160,15 @@ async function main(argv) {
160
160
  process.exitCode = 1;
161
161
  return;
162
162
  }
163
+ // `--help`/`-h` anywhere in a subcommand's own args short-circuits before
164
+ // any parsing or real action — a typo'd or exploratory flag (e.g. `mcp
165
+ // sync --help`) must never fall through to actually running the command
166
+ // it was asking about (real incident: `--help` isn't a flag any branch
167
+ // recognizes, so it silently ran a real, non-dry-run `mcp sync`).
168
+ if (rest.includes("--help") || rest.includes("-h")) {
169
+ printUsage();
170
+ return;
171
+ }
163
172
  if (command === "onboard") {
164
173
  const agentIndex = rest.indexOf("--agent");
165
174
  const agent = agentIndex >= 0 ? rest[agentIndex + 1] : undefined;
@@ -5,7 +5,7 @@
5
5
  * plan-then-apply split every adapter already uses.
6
6
  */
7
7
  import type { AgentId, McpServerDef } from "../core/types.js";
8
- export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict";
8
+ export type MigrateAction = "create" | "skip-symlink" | "skip-case-broken" | "skip-unsupported" | "already-migrated" | "reclassify" | "conflict" | "extract-secret";
9
9
  /** Internal kind naming, unchanged since before `--only` existed
10
10
  * (trellis-migrate-category-selection design.md D2) — the CLI-facing
11
11
  * flag value is the plural `"skills"`, mapped to this singular `"skill"`
@@ -24,8 +24,27 @@ export interface MigratePlanItem {
24
24
  /** Only set when action === "create"; consumed by applyMigratePlan. */
25
25
  sourceDir?: string;
26
26
  sourceContent?: string;
27
- /** Only set when kind === "mcp" && action === "create". */
27
+ /** Only set when kind === "mcp" && (action === "create" || "reclassify"
28
+ * || "extract-secret"). For "extract-secret" this is already the SAFE
29
+ * def — the flagged staticEnv value moved to a name-only `env` entry —
30
+ * never the literal (trellis-migrate-extract-static-env-secrets
31
+ * design.md D7). */
28
32
  mcpDef?: McpServerDef;
33
+ /** Only set when action === "extract-secret" — the variable name being
34
+ * extracted and the resolved path its real value will be written to.
35
+ * Deliberately never the real value itself, which never rides on this
36
+ * object at all (design.md D9); `applyMigratePlan` re-reads it fresh
37
+ * from the source agent at apply time. */
38
+ extractVarName?: string;
39
+ extractTargetPath?: string;
40
+ /** Only set when action === "extract-secret" and differs from
41
+ * `extractVarName` — the ORIGINAL `staticEnv` dict key in the source
42
+ * def, needed at apply time to know which key to read/remove there.
43
+ * `extractVarName` (design.md D11's `TRELLIS_<SERVER>_<KEY>` scheme) is
44
+ * a different, synthesized name — the two only coincide for an
45
+ * already-migrated server extracted before this naming scheme existed,
46
+ * which never reaches this action at all (already-migrated instead). */
47
+ extractSourceKey?: string;
29
48
  }
30
49
  export interface MigratePlan {
31
50
  agent: AgentId;
@@ -15,9 +15,34 @@ import { AGENTS_MD_TEMPLATE } from "./init.js";
15
15
  import { decideDirImport } from "../lib/dirEquals.js";
16
16
  import { deepEqual } from "../lib/deepEqual.js";
17
17
  import { readClaudeCodeMcpDefs, readCodexMcpDefs, readKiroMcpDefs, resolvedEnvTextMap } from "../lib/mcpMigrateRead.js";
18
+ import { parseDotenv, writeLocalSecretValue } from "../lib/secretEnv.js";
18
19
  import { findLiteralSecret } from "../adapters/mcpPlan.js";
19
- import { loadCanonicalSource, upsertServerYaml } from "../core/canonical.js";
20
+ import { ensureGitignoreEntry, ensureShellEnvSource, loadCanonicalSource, upsertServerYaml, writeSecretsPolicyExtraction } from "../core/canonical.js";
20
21
  import { ALL_AGENTS } from "../core/types.js";
22
+ /** Where a `staticEnv` literal secret's real value goes when
23
+ * `secrets.policy.yaml` doesn't already have its own `env_file`
24
+ * (trellis-migrate-extract-static-env-secrets design.md D3) — a sibling
25
+ * of `servers.yaml`, so "the mcp stuff" stays one directory rather than
26
+ * a second, disconnected location. `~`-form for what gets written into
27
+ * `secrets.policy.yaml` itself (portable across machines); resolved form
28
+ * for real file I/O. */
29
+ const DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE = "~/.trellis/mcp/servers.local.env";
30
+ function defaultLocalSecretsEnvFilePath(homeDir) {
31
+ return join(homeDir, ".trellis", "mcp", "servers.local.env");
32
+ }
33
+ /** Picks the rc file `ensureShellEnvSource` writes its one pointer block
34
+ * into, from `$SHELL` — zsh and bash are the only shells Trellis's own
35
+ * dev/CI machines and this feature's real-machine dogfooding have ever
36
+ * exercised; anything else falls back to `.profile`, the POSIX-sh default
37
+ * every shell still sources one way or another. */
38
+ function defaultShellRcPath(homeDir) {
39
+ const shell = process.env.SHELL ?? "";
40
+ if (shell.includes("zsh"))
41
+ return join(homeDir, ".zshrc");
42
+ if (shell.includes("bash"))
43
+ return join(homeDir, ".bash_profile");
44
+ return join(homeDir, ".profile");
45
+ }
21
46
  const PROBES = {
22
47
  "claude-code": (homeDir) => claudeCodeProbe.probe(homeDir),
23
48
  codex: (homeDir) => codexProbe.probe(homeDir),
@@ -110,24 +135,119 @@ export function isSafeReclassification(existing, def) {
110
135
  return false;
111
136
  return deepEqual(resolvedEnvTextMap(existing), resolvedEnvTextMap(def));
112
137
  }
113
- function planMcpServer(name, def, existing) {
114
- // Checked first, before any comparison against `existing`: a source
115
- // agent's real config holding a literal credential must never reach
116
- // canonical at all, regardless of whether canonical already has
117
- // something for this name. `resolveMcpPlan`'s own guard only protects
118
- // the sync-OUT boundary (canonical -> agent) — this is the matching
119
- // guard for the migrate-IN boundary (agent -> canonical), the gap a
120
- // real `mcp-router` token in a real `kiro` config exposed.
121
- const secretLabel = findLiteralSecret(def);
122
- if (secretLabel) {
138
+ /** Moves `sourceKey` out of `staticEnv` into a name-only `env` reference
139
+ * under `varName` — the def this produces never holds the literal that
140
+ * triggered extraction (trellis-migrate-extract-static-env-secrets
141
+ * design.md D7), safe to upsert into canonical or appear in any output.
142
+ * `sourceKey` and `varName` differ under D11's `TRELLIS_<SERVER>_<KEY>`
143
+ * naming scheme — the source dict key is not, in general, the name the
144
+ * value ends up referenced by. */
145
+ function extractedMcpDef(def, sourceKey, varName) {
146
+ const remainingStaticEnv = { ...(def.staticEnv ?? {}) };
147
+ delete remainingStaticEnv[sourceKey];
148
+ const result = { ...def, env: [...(def.env ?? []), varName] };
149
+ if (Object.keys(remainingStaticEnv).length > 0) {
150
+ result.staticEnv = remainingStaticEnv;
151
+ }
152
+ else {
153
+ delete result.staticEnv;
154
+ }
155
+ return result;
156
+ }
157
+ /** Uppercase, non-alphanumeric runs collapsed to a single `_`, no leading
158
+ * or trailing `_` — the one sanitization every synthesized name piece
159
+ * shares (design.md D11), so `mcp-router` becomes `MCP_ROUTER` and a
160
+ * header key like `X-Api-Key` becomes `X_API_KEY`. */
161
+ function sanitizeEnvNamePart(s) {
162
+ return s.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "X";
163
+ }
164
+ /** `TRELLIS_<SERVER>_<KEY>` — the project-wide prefix rules out a
165
+ * collision with anything already in the user's own environment or
166
+ * another tool's convention; the server-name segment rules out two
167
+ * Trellis-managed servers colliding with each other over the same key
168
+ * (design.md D11). Deliberately never the bare source key alone (what
169
+ * the code shipped with initially, and what an already-extracted server
170
+ * predating this scheme still uses — `planStaticEnvExtraction`'s own
171
+ * value-based lookup is what keeps recognizing those, not this
172
+ * function). */
173
+ function synthesizeVarName(serverName, key) {
174
+ return `TRELLIS_${sanitizeEnvNamePart(serverName)}_${sanitizeEnvNamePart(key)}`;
175
+ }
176
+ /**
177
+ * Only reached when `findLiteralSecret` matched inside `staticEnv` —
178
+ * `key` is that field's own dict key (design.md D1). Reads the target
179
+ * local-secrets file read-only to decide the outcome; never writes
180
+ * anything itself (planning stays dry-run-safe) and never puts the real
181
+ * value on the returned item (design.md D9) — only used here, in memory,
182
+ * to compare against what's already on disk.
183
+ *
184
+ * The variable name a *new* extraction is filed under is synthesized
185
+ * (design.md D11's `TRELLIS_<SERVER>_<KEY>`, not the bare `key`) — but an
186
+ * already-extracted server may have been extracted before this scheme
187
+ * existed, under the bare key or any other name. Rather than hardcoding
188
+ * "the old scheme was the bare key", this looks up `existing.env` by
189
+ * VALUE: any name already in canonical's `env` list whose local-secrets
190
+ * value already matches `realValue` means this server was already
191
+ * extracted, under whatever name that was, and must keep using it — not
192
+ * get a second, newly-synthesized reference alongside the first.
193
+ */
194
+ function planStaticEnvExtraction(name, def, existing, homeDir, policy, key) {
195
+ const realValue = def.staticEnv[key];
196
+ const targetPath = policy.envFile ?? defaultLocalSecretsEnvFilePath(homeDir);
197
+ const localSecrets = existsSync(targetPath) ? parseDotenv(readFileSync(targetPath, "utf-8")) : {};
198
+ const priorName = existing?.env?.find((n) => Object.hasOwn(localSecrets, n) && localSecrets[n] === realValue);
199
+ const varName = priorName ?? synthesizeVarName(name, key);
200
+ const safeDef = extractedMcpDef(def, key, varName);
201
+ const alreadyInSecretsFile = Object.hasOwn(localSecrets, varName);
202
+ if (priorName === undefined && alreadyInSecretsFile && localSecrets[varName] !== realValue) {
123
203
  return {
124
204
  kind: "mcp",
125
205
  name,
126
206
  action: "conflict",
127
- detail: `refusing to import MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) — canonical must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
128
- remediation: `move the real value in the source agent's own config to an environment variable (or your secrets manager), leaving only a name behind, then re-run migrate — canonical never accepts a literal credential, even briefly`,
207
+ detail: `refusing to extract MCP server "${name}"'s "${varName}": ${targetPath} already has a different value for this name — resolve by hand`,
208
+ remediation: `compare the source agent's current value for "${varName}" against the value already in ${targetPath} and update whichever one is stale, then re-run migrate`,
129
209
  };
130
210
  }
211
+ if (alreadyInSecretsFile && existing !== undefined && deepEqual(existing, safeDef)) {
212
+ return { kind: "mcp", name, action: "already-migrated", detail: `already extracted — "${varName}" is referenced from servers.yaml and its real value is already in ${targetPath}` };
213
+ }
214
+ if (existing !== undefined && !deepEqual(existing, safeDef) && !isSafeReclassification(existing, safeDef)) {
215
+ return {
216
+ kind: "mcp",
217
+ name,
218
+ action: "conflict",
219
+ detail: `canonical mcp/servers.yaml already has a different definition for "${name}" — resolve by hand`,
220
+ remediation: `compare the source agent's own definition for "${name}" against \`~/.trellis/mcp/servers.yaml\` and update canonical by hand if the source's is the one to keep`,
221
+ };
222
+ }
223
+ return {
224
+ kind: "mcp",
225
+ name,
226
+ action: "extract-secret",
227
+ detail: `will extract "${key}" to ${targetPath} as "${varName}", referencing it from servers.yaml instead of holding the literal value`,
228
+ mcpDef: safeDef,
229
+ extractVarName: varName,
230
+ extractTargetPath: targetPath,
231
+ extractSourceKey: key,
232
+ };
233
+ }
234
+ function planMcpServer(name, def, existing, homeDir, policy) {
235
+ // A `staticEnv` match is extracted (trellis-migrate-extract-static-env-secrets):
236
+ // its own dict key is a natural variable name, and `env`/`env_vars` is
237
+ // the one shape proven to resolve back to a real value for every
238
+ // consumer (pi-bridge, claude-code, codex, kiro). A literal anywhere
239
+ // else (`command`/`url`/`args`/`headers`) has either no natural name to
240
+ // extract to, or no `${VAR}` resolution mechanism proven across every
241
+ // consumer — a fake reference there would be strictly worse than the
242
+ // literal it replaced (an agent silently failing to connect instead of
243
+ // the source agent's own working config). Accepted as ordinary literal
244
+ // config instead (design.md D11), same as if no match were found at
245
+ // all; `secrets audit` keeps flagging canonical itself so this isn't
246
+ // silent.
247
+ const secretMatch = findLiteralSecret(def);
248
+ if (secretMatch?.field === "staticEnv") {
249
+ return planStaticEnvExtraction(name, def, existing, homeDir, policy, secretMatch.key);
250
+ }
131
251
  if (existing === undefined) {
132
252
  return { kind: "mcp", name, action: "create", detail: "will add to servers.yaml", mcpDef: def };
133
253
  }
@@ -176,10 +296,10 @@ export async function collectMigratePlan(agent, homeDir = homedir(), only) {
176
296
  if (wants("mcp")) {
177
297
  const reader = MCP_READERS[agent];
178
298
  if (reader) {
179
- const canonicalServers = loadCanonicalSource(homeDir).mcp.servers;
299
+ const canonical = loadCanonicalSource(homeDir);
180
300
  const { entries, unsupported } = reader(homeDir);
181
301
  for (const { name, def } of entries) {
182
- items.push(planMcpServer(name, def, canonicalServers[name]));
302
+ items.push(planMcpServer(name, def, canonical.mcp.servers[name], homeDir, canonical.secretsPolicy));
183
303
  }
184
304
  for (const { name, reason } of unsupported) {
185
305
  items.push({ kind: "mcp", name, action: "skip-unsupported", detail: reason });
@@ -188,10 +308,32 @@ export async function collectMigratePlan(agent, homeDir = homedir(), only) {
188
308
  }
189
309
  return { agent, present: true, items };
190
310
  }
311
+ /**
312
+ * Re-reads the source agent's real MCP entries at apply time to recover
313
+ * `sourceKey`'s current value — deliberately not carried on the plan item
314
+ * itself (design.md D9), so it never rides through a `--json`/`--dry-run`
315
+ * plan object a caller might log or serialize. A cheap, synchronous
316
+ * re-read (the same `MCP_READERS` function `collectMigratePlan` already
317
+ * called), not a second network/probe round-trip. `sourceKey` (the
318
+ * source's own dict key) and `varName` (the synthesized name it's
319
+ * written under, design.md D11) are looked up and written under
320
+ * separately — they are not, in general, the same string.
321
+ */
322
+ function applyStaticEnvExtraction(agent, serverName, sourceKey, varName, targetPath, homeDir) {
323
+ const reader = MCP_READERS[agent];
324
+ const realValue = reader?.(homeDir).entries.find((e) => e.name === serverName)?.def.staticEnv?.[sourceKey];
325
+ if (realValue === undefined)
326
+ return; // source changed between plan and apply; nothing left to extract
327
+ if (targetPath === defaultLocalSecretsEnvFilePath(homeDir)) {
328
+ ensureGitignoreEntry(join(homeDir, ".trellis", ".gitignore"), "mcp/servers.local.env");
329
+ }
330
+ writeLocalSecretValue(targetPath, varName, realValue);
331
+ writeSecretsPolicyExtraction(join(homeDir, ".trellis", "secrets.policy.yaml"), { varName, envFilePath: DEFAULT_LOCAL_SECRETS_ENV_FILE_TILDE });
332
+ }
191
333
  export function applyMigratePlan(plan, homeDir = homedir()) {
192
334
  const canonicalRoot = join(homeDir, ".trellis");
193
335
  for (const item of plan.items) {
194
- if (item.action !== "create" && item.action !== "reclassify")
336
+ if (item.action !== "create" && item.action !== "reclassify" && item.action !== "extract-secret")
195
337
  continue;
196
338
  if (item.kind === "skill" && item.sourceDir) {
197
339
  const dest = join(canonicalRoot, "skills", item.name);
@@ -204,8 +346,19 @@ export function applyMigratePlan(plan, homeDir = homedir()) {
204
346
  }
205
347
  else if (item.kind === "mcp" && item.mcpDef) {
206
348
  upsertServerYaml(join(canonicalRoot, "mcp", "servers.yaml"), item.name, item.mcpDef);
349
+ if (item.action === "extract-secret" && item.extractVarName && item.extractTargetPath && item.extractSourceKey) {
350
+ applyStaticEnvExtraction(plan.agent, item.name, item.extractSourceKey, item.extractVarName, item.extractTargetPath, homeDir);
351
+ }
207
352
  }
208
353
  }
354
+ // Runs every real (non-dry-run) migrate invocation, not just the one that
355
+ // performed a fresh extraction — a machine that already extracted a
356
+ // secret before this shell-wiring existed gets fixed the next time
357
+ // migrate runs at all, not only on its next brand-new extraction.
358
+ const envFile = loadCanonicalSource(homeDir).secretsPolicy.envFile;
359
+ if (envFile) {
360
+ ensureShellEnvSource(defaultShellRcPath(homeDir), envFile);
361
+ }
209
362
  }
210
363
  const ONLY_VALUES = ["skills", "instructions", "mcp"];
211
364
  function isMigrateOnlyValue(value) {
@@ -69,9 +69,13 @@ function logProgress(opts, stage) {
69
69
  const index = PROGRESS_STAGES.indexOf(stage) + 1;
70
70
  console.error(`[${index}/${PROGRESS_STAGES.length}] ${stage}`);
71
71
  }
72
+ /** A count only, never the full skill name list — with 30+ skills this
73
+ * used to render as one unreadable, wrapping wall of text per row
74
+ * (found via a real mirasim terminal session). The full names are still
75
+ * available from `trellis doctor`/`--json`; a picker row's job is to
76
+ * let you tell agents apart at a glance, not enumerate everything. */
72
77
  function agentSummaryLabel(s) {
73
- const skills = s.skillCount > 0 ? ` (${s.skillNames.join(", ")})` : "";
74
- return `${s.agent} — ${s.skillCount} skill(s)${skills}, instructions: ${s.hasRealInstructions ? "yes" : "no"}, mcp: ${s.mcpServerCount}`;
78
+ return `${s.agent} — ${s.skillCount} skill(s), instructions: ${s.hasRealInstructions ? "yes" : "no"}, mcp: ${s.mcpServerCount}`;
75
79
  }
76
80
  /** Numbered-typing fallback (trellis-onboard-interactive-picker design.md
77
81
  * D2) — used only when the terminal can't support the raw-mode picker
@@ -42,12 +42,25 @@ export function normalizeMigrateVerdict(plan) {
42
42
  return [];
43
43
  const items = [];
44
44
  for (const item of plan.items) {
45
- if (item.action !== "conflict")
46
- continue;
47
- const verdict = { stage: "migrate", severity: "blocked", message: item.detail };
48
- if (item.remediation)
49
- verdict.remediation = item.remediation;
50
- items.push(verdict);
45
+ if (item.action === "conflict") {
46
+ const verdict = { stage: "migrate", severity: "blocked", message: item.detail };
47
+ if (item.remediation)
48
+ verdict.remediation = item.remediation;
49
+ items.push(verdict);
50
+ }
51
+ else if (item.action === "extract-secret") {
52
+ // The run succeeded — never `blocked` — but stands as a standing
53
+ // reminder, not a silent success: migrate never writes to the
54
+ // source agent's own file (design.md D2), so it still holds the
55
+ // real value in plaintext forever, independent of this extraction
56
+ // (trellis-migrate-extract-static-env-secrets design.md D8).
57
+ items.push({
58
+ stage: "migrate",
59
+ severity: "warning",
60
+ message: item.detail,
61
+ remediation: `the source agent's own configuration for "${item.name}" was not modified and may still hold "${item.extractVarName}"'s real value in plaintext — clean it up by hand if you want it gone from there too`,
62
+ });
63
+ }
51
64
  }
52
65
  return items;
53
66
  }
@@ -103,7 +116,7 @@ export function normalizeSecretsAuditVerdict(report) {
103
116
  return [];
104
117
  return report.findings.map((finding) => {
105
118
  const item = { stage: "secrets audit", severity: "blocked", message: `${finding.file}: ${finding.detail}` };
106
- if (finding.agent !== "environment")
119
+ if (finding.agent !== "environment" && finding.agent !== "canonical")
107
120
  item.agent = finding.agent;
108
121
  return item;
109
122
  });
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * `trellis secrets audit` — reads every present, MCP-capable agent's real
3
- * config file (never the canonical source) and checks it against
4
- * `CanonicalSource.secretsPolicy`: a literal-value scan against
5
- * `rejectPatterns`, and a declared-env-var-name check against
6
- * `allowedVars`. Also checks, agent-agnostically, whether every env var
7
- * name declared across canonical `mcp.servers[*].env` actually resolves
8
- * to a value via the same `resolveSecretEnv` the pi bridge uses
9
- * (trellis-secrets-env-management) — authoritative for pi, a best-effort
10
- * proxy for the other three (design.md D3 in that change). Read-only —
11
- * never writes anything. Fails non-zero on any finding
12
- * (trellis-secrets-audit-p3).
3
+ * config file and checks it against `CanonicalSource.secretsPolicy`: a
4
+ * literal-value scan against `rejectPatterns`, and a declared-env-var-name
5
+ * check against `allowedVars`. Also checks, agent-agnostically, whether
6
+ * every env var name declared across canonical `mcp.servers[*].env`
7
+ * actually resolves to a value via the same `resolveSecretEnv` the pi
8
+ * bridge uses (trellis-secrets-env-management) — authoritative for pi, a
9
+ * best-effort proxy for the other three (design.md D3 in that change).
10
+ *
11
+ * Also scans canonical's own `mcp/servers.yaml` for the reject-pattern
12
+ * literal-value check (trellis-migrate-extract-static-env-secrets design.md
13
+ * D11): a literal in `command`/`url`/`args`/`headers` is now accepted into
14
+ * canonical by `migrate` rather than refused (no natural name to extract
15
+ * to, or no `${VAR}` resolution proven for every consumer), so canonical
16
+ * is no longer guaranteed clean by construction the way it used to be —
17
+ * this keeps that acceptance from being silent. Never the
18
+ * unexpected-var-name check, which is about agent-native serialized
19
+ * `env`/`env_vars` syntax specifically. Read-only — never writes
20
+ * anything. Fails non-zero on any finding (trellis-secrets-audit-p3).
13
21
  */
14
22
  import type { AgentId } from "../core/types.js";
15
23
  export interface RunSecretsAuditOptions {
@@ -23,8 +31,9 @@ export interface RunSecretsAuditOptions {
23
31
  }
24
32
  export interface SecretsFinding {
25
33
  /** "environment" for `missing-env-value` — that check isn't scoped to
26
- * any single agent's config file (see module doc comment). */
27
- agent: AgentId | "environment";
34
+ * any single agent's config file. "canonical" for a literal found in
35
+ * `~/.trellis/mcp/servers.yaml` itself (see module doc comment). */
36
+ agent: AgentId | "environment" | "canonical";
28
37
  file: string;
29
38
  kind: "literal-secret" | "unexpected-var-name" | "missing-env-value";
30
39
  detail: string;
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * `trellis secrets audit` — reads every present, MCP-capable agent's real
3
- * config file (never the canonical source) and checks it against
4
- * `CanonicalSource.secretsPolicy`: a literal-value scan against
5
- * `rejectPatterns`, and a declared-env-var-name check against
6
- * `allowedVars`. Also checks, agent-agnostically, whether every env var
7
- * name declared across canonical `mcp.servers[*].env` actually resolves
8
- * to a value via the same `resolveSecretEnv` the pi bridge uses
9
- * (trellis-secrets-env-management) — authoritative for pi, a best-effort
10
- * proxy for the other three (design.md D3 in that change). Read-only —
11
- * never writes anything. Fails non-zero on any finding
12
- * (trellis-secrets-audit-p3).
3
+ * config file and checks it against `CanonicalSource.secretsPolicy`: a
4
+ * literal-value scan against `rejectPatterns`, and a declared-env-var-name
5
+ * check against `allowedVars`. Also checks, agent-agnostically, whether
6
+ * every env var name declared across canonical `mcp.servers[*].env`
7
+ * actually resolves to a value via the same `resolveSecretEnv` the pi
8
+ * bridge uses (trellis-secrets-env-management) — authoritative for pi, a
9
+ * best-effort proxy for the other three (design.md D3 in that change).
10
+ *
11
+ * Also scans canonical's own `mcp/servers.yaml` for the reject-pattern
12
+ * literal-value check (trellis-migrate-extract-static-env-secrets design.md
13
+ * D11): a literal in `command`/`url`/`args`/`headers` is now accepted into
14
+ * canonical by `migrate` rather than refused (no natural name to extract
15
+ * to, or no `${VAR}` resolution proven for every consumer), so canonical
16
+ * is no longer guaranteed clean by construction the way it used to be —
17
+ * this keeps that acceptance from being silent. Never the
18
+ * unexpected-var-name check, which is about agent-native serialized
19
+ * `env`/`env_vars` syntax specifically. Read-only — never writes
20
+ * anything. Fails non-zero on any finding (trellis-secrets-audit-p3).
13
21
  */
14
22
  import { existsSync, readFileSync } from "node:fs";
15
23
  import { homedir } from "node:os";
@@ -57,6 +65,26 @@ function findMissingEnvValues(servers, policy) {
57
65
  detail: `"${name}" is declared by a canonical MCP server's env but has no resolvable value`,
58
66
  }));
59
67
  }
68
+ /**
69
+ * Reject-pattern-only scan of canonical's own `mcp/servers.yaml` (design.md
70
+ * D11) — no `unexpected-var-name` check here, that one is specifically
71
+ * about names extracted from an agent's own serialized `env`/`env_vars`
72
+ * syntax, not canonical's `env:` name list (which holding a name matching
73
+ * a reject pattern would be an absurd false positive, not a real finding).
74
+ */
75
+ function auditCanonicalServersYaml(homeDir, policy) {
76
+ const file = join(homeDir, ".trellis", "mcp", "servers.yaml");
77
+ if (!existsSync(file))
78
+ return [];
79
+ const content = readFileSync(file, "utf-8");
80
+ const findings = [];
81
+ for (const pattern of policy.rejectPatterns) {
82
+ if (pattern.test(content)) {
83
+ findings.push({ agent: "canonical", file, kind: "literal-secret", detail: `matches reject pattern ${pattern}` });
84
+ }
85
+ }
86
+ return findings;
87
+ }
60
88
  function auditFile(agent, file, content, policy, extractNames) {
61
89
  const findings = [];
62
90
  for (const pattern of policy.rejectPatterns) {
@@ -88,6 +116,7 @@ export async function collectSecretsAuditReport(opts = {}) {
88
116
  findings.push(...auditFile(agent.id, file, content, canonical.secretsPolicy, agent.extractNames));
89
117
  }
90
118
  findings.push(...findMissingEnvValues(canonical.mcp.servers, canonical.secretsPolicy));
119
+ findings.push(...auditCanonicalServersYaml(homeDir, canonical.secretsPolicy));
91
120
  return { findings };
92
121
  }
93
122
  export async function runSecretsAudit(opts = {}) {
@@ -57,6 +57,52 @@ export type McpMode = {
57
57
  * (design.md D3) — never left for the caller to remember.
58
58
  */
59
59
  export declare function writeMcpModeYaml(path: string, mode: McpMode): ServersYamlWriteResult;
60
+ export type SecretsPolicyWriteResult = {
61
+ ok: true;
62
+ } | {
63
+ ok: false;
64
+ error: string;
65
+ };
66
+ /**
67
+ * `trellis migrate`'s static-env secret extraction
68
+ * (trellis-migrate-extract-static-env-secrets design.md D6) — the
69
+ * missing writer for `secrets.policy.yaml`, mirroring `writeMcpModeYaml`'s
70
+ * `Document`-based, comment-preserving mechanism. Sets `env_file` only
71
+ * when it is not already set — an already-configured value is a
72
+ * deliberate prior choice (design.md D3) and `resolveSecretEnv` treats
73
+ * `env_file` as the sole source once set, so silently repointing it
74
+ * would orphan every name already resolving from the old file. Appends
75
+ * `varName` to `allowed_vars` only if not already present (dedup, same
76
+ * "already there is a no-op" rule every other Trellis writer follows).
77
+ */
78
+ export declare function writeSecretsPolicyExtraction(path: string, extraction: {
79
+ varName: string;
80
+ envFilePath: string;
81
+ }): SecretsPolicyWriteResult;
82
+ /**
83
+ * Ensures one exact line is present in a `.gitignore` file, creating the
84
+ * file if it doesn't exist yet — idempotent (a no-op if the line is
85
+ * already there), never disturbing any other line
86
+ * (trellis-migrate-extract-static-env-secrets design.md D5). Called
87
+ * lazily, immediately before the first real write to the local secrets
88
+ * file migrate's static-env extraction produces — never from `trellis
89
+ * init`'s own bootstrap, so a machine that never extracts a secret never
90
+ * gains this file at all.
91
+ */
92
+ export declare function ensureGitignoreEntry(gitignorePath: string, line: string): void;
93
+ /**
94
+ * Ensures a shell rc file sources the local secrets env file on every new
95
+ * shell — one generic pointer block, appended once, idempotent (detected
96
+ * by its own marker comment, never duplicated). Deliberately never writes
97
+ * a literal secret value into the rc file: the block only names the path
98
+ * to the real `NAME=value` file migrate's extraction already writes and
99
+ * protects — adding a new variable later means editing that one file, the
100
+ * rc file never needs a second edit (trellis-migrate-extract-static-env-secrets
101
+ * design.md D10). `set -a`/`set +a` auto-exports the plain dotenv-format
102
+ * lines `parseDotenv` already expects, so that format never needs an
103
+ * `export` prefix of its own.
104
+ */
105
+ export declare function ensureShellEnvSource(rcPath: string, envFilePath: string): void;
60
106
  /**
61
107
  * `homeDir` defaults to the real `~` and is only ever overridden for tests
62
108
  * and `scripts/sandbox.sh` — the same seam P0's probes use
@@ -4,10 +4,10 @@
4
4
  * workspace scope"). See openspec/changes/trellis-sync-p1/specs/
5
5
  * canonical-source-loading/spec.md for the exact contract this implements.
6
6
  */
7
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
- import { basename, join } from "node:path";
10
- import { isMap, parse as parseYaml, parseDocument } from "yaml";
9
+ import { basename, dirname, join } from "node:path";
10
+ import { isMap, isSeq, parse as parseYaml, parseDocument } from "yaml";
11
11
  import { ALL_AGENTS } from "./types.js";
12
12
  function fromServerDefYaml(def) {
13
13
  const { static_env, env_aliases, ...rest } = def;
@@ -126,8 +126,14 @@ function fromGatewayYaml(gateway) {
126
126
  * one-line, local fix — it never touches any sibling entry's own style,
127
127
  * so a file someone deliberately kept flow-style elsewhere is untouched.
128
128
  */
129
+ /** Handles both a map (`servers: {}`) and a sequence (`allowed_vars: []`)
130
+ * — `trellis init`'s starter files use flow style for both kinds of
131
+ * still-empty collection, and inserting into either one via `Document`
132
+ * methods keeps rendering it as flow otherwise (same real bug this
133
+ * function was first written to fix, just a second collection type
134
+ * hitting it — trellis-migrate-extract-static-env-secrets). */
129
135
  function forceBlockStyle(node) {
130
- if (isMap(node)) {
136
+ if (isMap(node) || isSeq(node)) {
131
137
  node.flow = false;
132
138
  }
133
139
  }
@@ -222,6 +228,84 @@ function loadSecretsPolicyYaml(path, homeDir) {
222
228
  envFile: parsed.env_file ? parsed.env_file.replace(/^~(?=$|\/)/, homeDir) : undefined,
223
229
  };
224
230
  }
231
+ /**
232
+ * `trellis migrate`'s static-env secret extraction
233
+ * (trellis-migrate-extract-static-env-secrets design.md D6) — the
234
+ * missing writer for `secrets.policy.yaml`, mirroring `writeMcpModeYaml`'s
235
+ * `Document`-based, comment-preserving mechanism. Sets `env_file` only
236
+ * when it is not already set — an already-configured value is a
237
+ * deliberate prior choice (design.md D3) and `resolveSecretEnv` treats
238
+ * `env_file` as the sole source once set, so silently repointing it
239
+ * would orphan every name already resolving from the old file. Appends
240
+ * `varName` to `allowed_vars` only if not already present (dedup, same
241
+ * "already there is a no-op" rule every other Trellis writer follows).
242
+ */
243
+ export function writeSecretsPolicyExtraction(path, extraction) {
244
+ if (!existsSync(path)) {
245
+ return { ok: false, error: `${path} does not exist — run \`trellis init\` first` };
246
+ }
247
+ let doc;
248
+ try {
249
+ doc = parseDocument(readFileSync(path, "utf-8"));
250
+ }
251
+ catch (err) {
252
+ return { ok: false, error: `could not parse ${path}: ${err instanceof Error ? err.message : String(err)}` };
253
+ }
254
+ if (doc.get("env_file") === undefined) {
255
+ doc.set("env_file", extraction.envFilePath);
256
+ }
257
+ // `.get()` returns the raw Seq node for a collection, not a plain
258
+ // array — `.toJS()` on the whole document is the reliable way to read
259
+ // a fully-unwrapped value back out before deciding whether to append.
260
+ const currentAllowedVars = (doc.toJS().allowed_vars ?? []);
261
+ if (!currentAllowedVars.includes(extraction.varName)) {
262
+ doc.set("allowed_vars", [...currentAllowedVars, extraction.varName]);
263
+ forceBlockStyle(doc.get("allowed_vars", true));
264
+ }
265
+ writeFileSync(path, doc.toString());
266
+ return { ok: true };
267
+ }
268
+ /**
269
+ * Ensures one exact line is present in a `.gitignore` file, creating the
270
+ * file if it doesn't exist yet — idempotent (a no-op if the line is
271
+ * already there), never disturbing any other line
272
+ * (trellis-migrate-extract-static-env-secrets design.md D5). Called
273
+ * lazily, immediately before the first real write to the local secrets
274
+ * file migrate's static-env extraction produces — never from `trellis
275
+ * init`'s own bootstrap, so a machine that never extracts a secret never
276
+ * gains this file at all.
277
+ */
278
+ export function ensureGitignoreEntry(gitignorePath, line) {
279
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
280
+ const lines = existing.split("\n").map((l) => l.trim());
281
+ if (lines.includes(line))
282
+ return;
283
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
284
+ writeFileSync(gitignorePath, `${existing}${separator}${line}\n`);
285
+ }
286
+ const SHELL_ENV_SOURCE_MARKER = "# >>> trellis mcp secrets >>>";
287
+ const SHELL_ENV_SOURCE_END_MARKER = "# <<< trellis mcp secrets <<<";
288
+ /**
289
+ * Ensures a shell rc file sources the local secrets env file on every new
290
+ * shell — one generic pointer block, appended once, idempotent (detected
291
+ * by its own marker comment, never duplicated). Deliberately never writes
292
+ * a literal secret value into the rc file: the block only names the path
293
+ * to the real `NAME=value` file migrate's extraction already writes and
294
+ * protects — adding a new variable later means editing that one file, the
295
+ * rc file never needs a second edit (trellis-migrate-extract-static-env-secrets
296
+ * design.md D10). `set -a`/`set +a` auto-exports the plain dotenv-format
297
+ * lines `parseDotenv` already expects, so that format never needs an
298
+ * `export` prefix of its own.
299
+ */
300
+ export function ensureShellEnvSource(rcPath, envFilePath) {
301
+ const existing = existsSync(rcPath) ? readFileSync(rcPath, "utf-8") : "";
302
+ if (existing.includes(SHELL_ENV_SOURCE_MARKER))
303
+ return;
304
+ const separator = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
305
+ const block = `${SHELL_ENV_SOURCE_MARKER}\nif [ -f "${envFilePath}" ]; then\n set -a\n source "${envFilePath}"\n set +a\nfi\n${SHELL_ENV_SOURCE_END_MARKER}\n`;
306
+ mkdirSync(dirname(rcPath), { recursive: true });
307
+ writeFileSync(rcPath, `${existing}${separator}${block}`);
308
+ }
225
309
  /** `undefined` if the name has no entry in scope.yaml's map — "shared with
226
310
  * all four agents," the default. A recognized but empty list is left as
227
311
  * authored (an explicitly agent-less scope), not coerced to "all". */
@@ -17,3 +17,19 @@ export declare function parseDotenv(content: string): Record<string, string>;
17
17
  * reads `process.env` directly, identical to every pre-existing caller.
18
18
  */
19
19
  export declare function resolveSecretEnv(names: string[], policy: SecretsPolicy): Record<string, string | undefined>;
20
+ export type WriteLocalSecretOutcome = "created" | "already-present" | "conflict";
21
+ /**
22
+ * `trellis migrate`'s static-env secret extraction
23
+ * (trellis-migrate-extract-static-env-secrets design.md D4) — appends
24
+ * one `NAME=value` line to a dotenv-format file in exactly the shape
25
+ * `parseDotenv` above already reads, creating the file (and its parent
26
+ * directory) if neither exists yet. Idempotent, the same three-way
27
+ * split every other Trellis writer uses: the name is absent (create),
28
+ * already present with the identical value (no-op — re-running migrate
29
+ * must not duplicate the line), or already present with a *different*
30
+ * value (conflict, left untouched — the file may hold a value the user
31
+ * already rotated by hand since the last run; silently overwriting it
32
+ * would be exactly the kind of value-clobbering this whole feature
33
+ * exists to prevent, just relocated to a new file).
34
+ */
35
+ export declare function writeLocalSecretValue(path: string, name: string, value: string): WriteLocalSecretOutcome;
@@ -8,7 +8,8 @@
8
8
  * dotenv library's quoting/escaping/interpolation rules are more than
9
9
  * this narrow need requires (design.md D4).
10
10
  */
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
12
+ import { dirname } from "node:path";
12
13
  const LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
13
14
  export function parseDotenv(content) {
14
15
  const result = {};
@@ -44,3 +45,28 @@ export function resolveSecretEnv(names, policy) {
44
45
  }
45
46
  return result;
46
47
  }
48
+ /**
49
+ * `trellis migrate`'s static-env secret extraction
50
+ * (trellis-migrate-extract-static-env-secrets design.md D4) — appends
51
+ * one `NAME=value` line to a dotenv-format file in exactly the shape
52
+ * `parseDotenv` above already reads, creating the file (and its parent
53
+ * directory) if neither exists yet. Idempotent, the same three-way
54
+ * split every other Trellis writer uses: the name is absent (create),
55
+ * already present with the identical value (no-op — re-running migrate
56
+ * must not duplicate the line), or already present with a *different*
57
+ * value (conflict, left untouched — the file may hold a value the user
58
+ * already rotated by hand since the last run; silently overwriting it
59
+ * would be exactly the kind of value-clobbering this whole feature
60
+ * exists to prevent, just relocated to a new file).
61
+ */
62
+ export function writeLocalSecretValue(path, name, value) {
63
+ const existingContent = existsSync(path) ? readFileSync(path, "utf-8") : "";
64
+ const existing = parseDotenv(existingContent);
65
+ if (Object.hasOwn(existing, name)) {
66
+ return existing[name] === value ? "already-present" : "conflict";
67
+ }
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ const separator = existingContent.length > 0 && !existingContent.endsWith("\n") ? "\n" : "";
70
+ appendFileSync(path, `${separator}${name}=${value}\n`);
71
+ return "created";
72
+ }
@@ -28,6 +28,27 @@ export declare function canUseInteractivePicker(streams?: PickerStreams): boolea
28
28
  export declare function nextIndex(current: number, delta: number, length: number): number;
29
29
  /** Pure single-index toggle — exported for direct unit testing. */
30
30
  export declare function toggled(checked: readonly boolean[], index: number): boolean[];
31
+ /** A row's *visible* width, excluding the color codes wrapped around it —
32
+ * they add characters that occupy zero terminal columns. Needed to
33
+ * compute how many physical lines a row actually wraps to (see
34
+ * `physicalLineCount`); counting `row.length` directly would overcount
35
+ * and under-clear on the next redraw. */
36
+ export declare function visibleWidth(row: string): number;
37
+ /**
38
+ * How many physical terminal lines one logical row occupies once the
39
+ * terminal wraps it — `Math.ceil(visibleWidth / columns)`, at least 1.
40
+ * `clearLines`/`moveCursorUp` need this, not `rows.length`: a picker
41
+ * item whose rendered text is longer than the terminal is wide (a long
42
+ * agent summary, a narrow terminal, or both) wraps onto more than one
43
+ * physical line, and erasing/moving by the *logical* row count instead
44
+ * leaves the wrapped remainder of the previous frame on screen — found
45
+ * via a real mirasim terminal session where a (much longer,
46
+ * pre-truncation) row's wrapped tail was never cleared, so every redraw
47
+ * appended a new, only-partially-overwritten copy instead of replacing
48
+ * the old one.
49
+ */
50
+ export declare function physicalLineCount(row: string, columns: number): number;
51
+ export declare function totalPhysicalLines(rows: readonly string[], columns: number): number;
31
52
  /**
32
53
  * Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
33
54
  * the confirmed index, or `null` on Ctrl+C cancel. Caller (onboard.ts)
@@ -40,6 +40,45 @@ const ESC = "\u001b";
40
40
  const CTRL_C = "\u0003";
41
41
  const HIDE_CURSOR = `${ESC}[?25l`;
42
42
  const SHOW_CURSOR = `${ESC}[?25h`;
43
+ const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
44
+ /** A row's *visible* width, excluding the color codes wrapped around it —
45
+ * they add characters that occupy zero terminal columns. Needed to
46
+ * compute how many physical lines a row actually wraps to (see
47
+ * `physicalLineCount`); counting `row.length` directly would overcount
48
+ * and under-clear on the next redraw. */
49
+ export function visibleWidth(row) {
50
+ return row.replace(ANSI_ESCAPE_RE, "").length;
51
+ }
52
+ /**
53
+ * How many physical terminal lines one logical row occupies once the
54
+ * terminal wraps it — `Math.ceil(visibleWidth / columns)`, at least 1.
55
+ * `clearLines`/`moveCursorUp` need this, not `rows.length`: a picker
56
+ * item whose rendered text is longer than the terminal is wide (a long
57
+ * agent summary, a narrow terminal, or both) wraps onto more than one
58
+ * physical line, and erasing/moving by the *logical* row count instead
59
+ * leaves the wrapped remainder of the previous frame on screen — found
60
+ * via a real mirasim terminal session where a (much longer,
61
+ * pre-truncation) row's wrapped tail was never cleared, so every redraw
62
+ * appended a new, only-partially-overwritten copy instead of replacing
63
+ * the old one.
64
+ */
65
+ export function physicalLineCount(row, columns) {
66
+ if (columns <= 0)
67
+ return 1;
68
+ return Math.max(1, Math.ceil(visibleWidth(row) / columns));
69
+ }
70
+ export function totalPhysicalLines(rows, columns) {
71
+ return rows.reduce((sum, row) => sum + physicalLineCount(row, columns), 0);
72
+ }
73
+ /** `output.columns` only exists on a real TTY; test-injected plain
74
+ * streams (`PickerStreams`) have none. Defaulting to 80 — the standard
75
+ * terminal width, and this project's own test fixtures — keeps the
76
+ * wrap-aware math above exercised (and testable) even without a real
77
+ * terminal, rather than silently reverting to "assume no row ever wraps"
78
+ * for every non-TTY stream. */
79
+ function columnsOf(output) {
80
+ return output.columns ?? 80;
81
+ }
43
82
  /** Parses one raw input chunk into a single logical key — arrow escape
44
83
  * sequences, j/k, space, enter, or Ctrl+C. Anything else is ignored. */
45
84
  function parseKey(chunk) {
@@ -92,16 +131,30 @@ function clearLines(output, lines) {
92
131
  }
93
132
  moveCursorUp(output, lines - 1);
94
133
  }
95
- function renderRows(output, rows, previousRowCount) {
96
- if (previousRowCount > 0) {
97
- clearLines(output, previousRowCount);
134
+ /**
135
+ * `previousPhysicalLines` must be the actual on-screen line count the
136
+ * *previous* call to this function produced (`totalPhysicalLines` of
137
+ * that frame's rows), not `rows.length` — see `physicalLineCount`'s doc
138
+ * comment for why a wrapped row makes those two numbers diverge.
139
+ */
140
+ function renderRows(output, rows, previousPhysicalLines) {
141
+ if (previousPhysicalLines > 0) {
142
+ clearLines(output, previousPhysicalLines);
98
143
  }
99
144
  for (const row of rows) {
100
145
  output.write(`${row}\n`);
101
146
  }
102
147
  }
148
+ /**
149
+ * A real foreground color (bold cyan), not just reverse video —
150
+ * differentiating the highlighted row must not depend on a host
151
+ * terminal correctly inverting fore/background, which a real mirasim
152
+ * terminal session showed no visible effect from at all. The `>`/` `
153
+ * marker stays regardless, as a plain-text fallback for a host that
154
+ * strips color entirely.
155
+ */
103
156
  function highlightRow(text, isHighlighted) {
104
- return isHighlighted ? `> ${ESC}[7m${text}${ESC}[0m` : ` ${text}`;
157
+ return isHighlighted ? `> ${ESC}[1;36m${text}${ESC}[0m` : ` ${text}`;
105
158
  }
106
159
  /**
107
160
  * Single-select: Up/Down/j/k moves the highlight, Enter confirms. Resolves
@@ -115,11 +168,12 @@ export async function runSingleSelectPicker(items, streams = defaultStreams()) {
115
168
  return withRawMode(streams, () => {
116
169
  return new Promise((resolve) => {
117
170
  let highlighted = 0;
118
- let rowCount = 0;
171
+ let physicalLines = 0;
172
+ const columns = columnsOf(output);
119
173
  const draw = () => {
120
174
  const rows = items.map((label, i) => highlightRow(label, i === highlighted));
121
- renderRows(output, rows, rowCount);
122
- rowCount = rows.length;
175
+ renderRows(output, rows, physicalLines);
176
+ physicalLines = totalPhysicalLines(rows, columns);
123
177
  };
124
178
  draw();
125
179
  const onData = (chunk) => {
@@ -157,11 +211,12 @@ export async function runMultiSelectPicker(items, initiallyChecked, streams = de
157
211
  return new Promise((resolve) => {
158
212
  let highlighted = 0;
159
213
  let checked = [...initiallyChecked];
160
- let rowCount = 0;
214
+ let physicalLines = 0;
215
+ const columns = columnsOf(output);
161
216
  const draw = () => {
162
217
  const rows = items.map((label, i) => highlightRow(`[${checked[i] ? "x" : " "}] ${label}`, i === highlighted));
163
- renderRows(output, rows, rowCount);
164
- rowCount = rows.length;
218
+ renderRows(output, rows, physicalLines);
219
+ physicalLines = totalPhysicalLines(rows, columns);
165
220
  };
166
221
  draw();
167
222
  const onData = (chunk) => {
@@ -55,7 +55,7 @@ var require_identity = __commonJS({
55
55
  var isMap2 = (node2) => !!node2 && typeof node2 === "object" && node2[NODE_TYPE] === MAP;
56
56
  var isPair = (node2) => !!node2 && typeof node2 === "object" && node2[NODE_TYPE] === PAIR;
57
57
  var isScalar = (node2) => !!node2 && typeof node2 === "object" && node2[NODE_TYPE] === SCALAR;
58
- var isSeq = (node2) => !!node2 && typeof node2 === "object" && node2[NODE_TYPE] === SEQ;
58
+ var isSeq2 = (node2) => !!node2 && typeof node2 === "object" && node2[NODE_TYPE] === SEQ;
59
59
  function isCollection(node2) {
60
60
  if (node2 && typeof node2 === "object")
61
61
  switch (node2[NODE_TYPE]) {
@@ -92,7 +92,7 @@ var require_identity = __commonJS({
92
92
  exports.isNode = isNode;
93
93
  exports.isPair = isPair;
94
94
  exports.isScalar = isScalar;
95
- exports.isSeq = isSeq;
95
+ exports.isSeq = isSeq2;
96
96
  }
97
97
  });
98
98
 
@@ -15140,9 +15140,9 @@ import { homedir as homedir2 } from "node:os";
15140
15140
 
15141
15141
  // src/core/canonical.ts
15142
15142
  var import_yaml = __toESM(require_dist(), 1);
15143
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
15143
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
15144
15144
  import { homedir } from "node:os";
15145
- import { basename, join } from "node:path";
15145
+ import { basename, dirname, join } from "node:path";
15146
15146
 
15147
15147
  // src/core/types.ts
15148
15148
  var ALL_AGENTS = [
@@ -15329,7 +15329,7 @@ function codexBearerTokenEnvVar(def) {
15329
15329
  }
15330
15330
 
15331
15331
  // src/lib/secretEnv.ts
15332
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
15332
+ import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "node:fs";
15333
15333
  var LINE_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
15334
15334
  function parseDotenv(content) {
15335
15335
  const result = {};
@@ -15379,13 +15379,17 @@ var DANGEROUS_LITERAL_PATTERNS = [
15379
15379
  { label: "mcp-router token (mcpr_)", pattern: /mcpr_/ }
15380
15380
  ];
15381
15381
  function findLiteralSecret(def) {
15382
- const candidates = [def.command, def.url, ...def.args ?? [], ...Object.values(def.headers ?? {}), ...Object.values(def.staticEnv ?? {})].filter(
15383
- (v) => typeof v === "string"
15384
- );
15385
- for (const candidate of candidates) {
15382
+ const candidates = [
15383
+ ...def.command !== void 0 ? [{ value: def.command, field: "command" }] : [],
15384
+ ...def.url !== void 0 ? [{ value: def.url, field: "url" }] : [],
15385
+ ...(def.args ?? []).map((value) => ({ value, field: "args" })),
15386
+ ...Object.entries(def.headers ?? {}).map(([key, value]) => ({ value, field: "headers", key })),
15387
+ ...Object.entries(def.staticEnv ?? {}).map(([key, value]) => ({ value, field: "staticEnv", key }))
15388
+ ];
15389
+ for (const { value, field, key } of candidates) {
15386
15390
  for (const { label, pattern } of DANGEROUS_LITERAL_PATTERNS) {
15387
- if (pattern.test(candidate)) {
15388
- return label;
15391
+ if (pattern.test(value)) {
15392
+ return { label, field, key };
15389
15393
  }
15390
15394
  }
15391
15395
  }
@@ -15432,11 +15436,11 @@ function resolveMcpPlan(agentId, mcp, managedAgents, policy) {
15432
15436
  conflicts.push({ name, message: collisionMessage(name, agentId), remediation: collisionRemediation(name) });
15433
15437
  continue;
15434
15438
  }
15435
- const secretLabel = findLiteralSecret(def);
15436
- if (secretLabel) {
15439
+ const secretMatch = findLiteralSecret(def);
15440
+ if (secretMatch?.field === "staticEnv") {
15437
15441
  conflicts.push({
15438
15442
  name,
15439
- message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretLabel}) \u2014 configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
15443
+ message: `refusing to write MCP server "${name}": a value matches a known-dangerous literal pattern (${secretMatch.label}) \u2014 configs must hold variable NAMES only, never real values (docs/research.md "Secrets")`,
15440
15444
  remediation: `replace the literal value in servers.yaml with a \`\${VAR_NAME}\` reference and put the real value wherever secrets.policy.yaml resolves it from`
15441
15445
  });
15442
15446
  continue;
@@ -288,6 +288,81 @@ Two known fidelity limits, named rather than silently worked around:
288
288
  server was hand-authored with some other shape, it migrates whatever
289
289
  is actually there, same as any other field.
290
290
 
291
+ **A real credential value found in a source agent's config is extracted,
292
+ not just refused.** If a source agent stores a real credential as a
293
+ literal (not a `${VAR}` reference) under a server's `staticEnv`-shaped
294
+ field — the exact real case this project hit: kiro's own `mcp-router`
295
+ entry had its token hardcoded — `migrate` moves the real value to
296
+ `~/.trellis/mcp/servers.local.env` (a sibling of `servers.yaml`,
297
+ automatically `.gitignore`d — never something you need to protect by
298
+ hand), references it from canonical as `${THE_NAME}` instead of the
299
+ literal, and adds the name to `secrets.policy.yaml`'s `allowed_vars`. If
300
+ `secrets.policy.yaml` already has its own `env_file` configured, the
301
+ value goes there instead, respecting your existing setup rather than
302
+ creating a second file.
303
+
304
+ ```
305
+ $ trellis migrate --from kiro --only mcp
306
+ migrate --from kiro
307
+ [extract-secret] mcp server "mcp-router" — will extract "MCPR_TOKEN" to
308
+ ~/.trellis/mcp/servers.local.env as "TRELLIS_MCP_ROUTER_MCPR_TOKEN",
309
+ referencing it from servers.yaml instead of holding the literal value
310
+ ```
311
+
312
+ The name it's extracted under is `TRELLIS_<SERVER>_<KEY>`, never the
313
+ bare source key alone — a project-wide prefix rules out colliding with
314
+ anything already in your own environment, and the server-name segment
315
+ rules out two Trellis-managed servers colliding with each other over
316
+ the same key. A server extracted before this naming scheme existed
317
+ keeps working under its original name — recognized by the value it
318
+ resolves to, not by re-deriving today's name and expecting an exact
319
+ match, so it's never silently re-extracted under a second name.
320
+
321
+ **The source agent's own file is never touched** — kiro's real
322
+ `~/.kiro/settings/mcp.json` keeps its literal value exactly as it was,
323
+ forever; `trellis secrets audit` will keep flagging that file on every
324
+ future run, which is correct and expected — cleaning it up by hand is
325
+ your call, not something this command does for you.
326
+
327
+ **A credential found anywhere *other* than `staticEnv`** (a server's
328
+ `command`, `url`, `args`, or `headers`) **is accepted as ordinary
329
+ literal config, not refused and not extracted.** Neither a natural
330
+ variable name (staticEnv's own dict key provides one; these fields
331
+ don't) nor a proven `${VAR}` resolution mechanism exists for these
332
+ fields across every consumer (pi-bridge, claude-code, codex, kiro) —
333
+ faking a reference would produce a config that looks safe but silently
334
+ fails to connect for at least some of them, worse than the literal it
335
+ replaced. `trellis secrets audit` also scans canonical's own
336
+ `mcp/servers.yaml` for this case (`agent: "canonical"` in its findings),
337
+ so accepting the literal is never silent either.
338
+
339
+ **The `${VAR}` reference `mcp sync` writes into an agent's native config
340
+ only works once that name is actually in the environment that agent's
341
+ own process reads from** — extraction alone doesn't get you there, it
342
+ just gets the real value out of canonical. So `migrate` also ensures
343
+ your shell rc (`~/.zshrc`/`~/.bash_profile`/`~/.profile`, picked from
344
+ `$SHELL`) sources `servers.local.env`, appending one generic,
345
+ idempotent block — never a literal secret line:
346
+
347
+ ```
348
+ # >>> trellis mcp secrets >>>
349
+ if [ -f "~/.trellis/mcp/servers.local.env" ]; then
350
+ set -a
351
+ source "~/.trellis/mcp/servers.local.env"
352
+ set +a
353
+ fi
354
+ # <<< trellis mcp secrets <<<
355
+ ```
356
+
357
+ Adding another secret later only ever means editing
358
+ `servers.local.env` — the rc file never needs a second edit. This runs
359
+ on every real `migrate` invocation whenever `secrets.policy.yaml` has an
360
+ `env_file`, not just the run that performed the extraction, so a machine
361
+ that already extracted a secret before this existed gets wired the next
362
+ time `migrate` runs at all. A new shell (or restarting the agent
363
+ process) is what actually picks up the newly-exported variable — this
364
+ only ensures the rc file is ready to hand it over.
365
+
291
366
  ## Starting from nothing
292
367
 
293
368
  Skip migrate. Edit `~/.trellis/agents.md` and add skills under
package/docs/roadmap.md CHANGED
@@ -954,6 +954,7 @@ tests passing.
954
954
  | P21 | ✅ MCP gateway hosting: collapses each agent's MCP config to ONE native stdio entry (`trellis mcp-gateway --agent <id>`) that Trellis itself spawns — no Docker, no daemon, no lifecycle command. Four third-party gateways were evaluated and excluded on hard constraints (MetaMCP is Docker-only; mcp-local-hub is Windows-only today; `@samanhappy/mcphub` is a 43-dependency authenticated web product with no importable SDK surface; `@pcandido/mcphub` has correct OAuth but zero `${VAR}` expansion in its stdio `env`, verified by inspecting what a spawned child actually received — adopting it would mean plaintext secrets or a second resolution path outside `secrets audit`). Built instead on `pi-bridge`'s already-proven connect logic, extracted to `src/lib/mcpConnect.ts`, behind a `GatewayBackend` seam so the confirmed next step — one shared service for all agents, with the per-session process becoming a thin MCP-over-Unix-socket client — is a substitution at one construction site, with every agent's written config identical either way. Includes Trellis's own OAuth client (RFC 8414/9728 discovery, RFC 7591 DCR, RFC 7636 PKCE S256, refresh), tested against a hand-built Authorization Server that *enforces* those RFCs rather than rubber-stamping them; tokens live 0600 one-file-per-server outside canonical, never the OS keychain (macOS `security add-generic-password` measurably fails exit 152/154 with no GUI session — the gateway's exact context). Two bugs the design caught before shipping: the MCP SDK's stdio transport reports no EOF, so without explicit teardown every agent session would leak a gateway plus its whole upstream process set; and concurrent OAuth refresh silently kills a rotated refresh token, so it is serialized by a per-server lock. Remaining: the shared service itself (v2) | P2 |
955
955
  | P22 | ✅ Onboard closed loop: onboard used to stop the moment files were written, never checking whether any of it took effect, and never telling the user how the run went — a real run with a genuine sync conflict still ended its printed output on a later stage's unrelated green line. Two distinct fixes, not one: a **self-verification re-plan** (the mechanism that actually closes the loop) runs `sync`/`mcp sync` a second time, dry-run, immediately after their real apply, and treats anything still outstanding as its own blocking finding — `trellis doctor` was tried first and rejected for this job, since its six detectors compare agents against each other and never read canonical, so they cannot prove a write took effect even in principle. Doctor still runs, last, as a secondary whole-machine health scan (never `--probe-mcp` — the worst moment to spawn every configured MCP server is a user's first run of this command), with findings on an unmanaged agent demoted to a warning rather than hidden. Every stage's conflicts and findings normalize into one `VerdictItem[]`, printed as a terminal verdict block that is always the last thing on screen and always agrees with the exit code, each blocking item carrying a concrete remediation instead of a restatement of the problem. Also adds stage progress to stderr (never stdout, so `onboard > report.txt` stays exactly the report) and, on a real terminal, a `--dry-run` that ends by offering to apply (declining is the default; accepting re-plans against current state rather than replaying a now-stale preview) | P0 |
956
956
  | P23 | ✅ Onboard MCP mode + memory toggle: closes the last hand-editing gap — turning on gateway or hub mode, or the shared memory server, meant directly editing `~/.trellis/mcp/servers.yaml`'s top-level `hub`/`gateway` keys or its commented-out `memory:` example, since no command wrote them (`trellis mcp add`'s flags are all per-server). New `writeMcpModeYaml` in `src/core/canonical.ts` (`Document`-based, mirrors `upsertServerYaml`'s comment-preserving edit) plus `trellis onboard --mcp-mode direct\|hub\|gateway` (`--hub-url`, `--gateway-agents`) and `--memory on\|off` close it. Both follow onboard's existing managed-set idempotency shape rather than a new one: read current state first, omitting the flag always preserves it untouched with zero prompting — identical on a first run (nothing configured) and a later one (something already is), which is the actual point: one command for both, not a setup path and a separate reconfigure path. Deliberately flag-only, never an interactive picker (a mode change reroutes every managed agent's MCP transport at once — too wide a blast radius for Enter-mashing through a prompt sequence to trigger by accident); a one-line, non-prompting status line on a real terminal keeps the flags discoverable anyway. `--memory on` refuses outright if `"memory"` is already in `known_host_injected`, rather than writing a definition the very next `mcp sync` would refuse to propagate to any agent. Because mode/memory resolution runs before onboard's existing `mcp sync`/`memory sync` stages in the same chain, `trellis onboard --memory on` enables the server, syncs it to every managed agent, and populates it from canonical `memories/*.md` — all in one run | P17, P21, P22 |
957
+ | P24 | ✅ Migrate static-env secret extraction: found via real-machine dogfooding — kiro's own real `mcp-router` server had its token hardcoded as a literal (not a `${VAR}` reference), which the existing literal-secret guard correctly refused to import, but the only path forward was three manual hand-edits across two files. A `staticEnv` match specifically (its own dict key is already a natural variable name) is now extracted automatically instead of refused: the real value moves to `~/.trellis/mcp/servers.local.env` (a sibling of `servers.yaml`, or an already-configured `secrets.policy.yaml` `env_file` if one exists — respecting a prior explicit choice rather than repointing it), canonical gets a `${NAME}` reference, `secrets.policy.yaml` gains the name in `allowed_vars`, and `~/.trellis/.gitignore` is ensured to protect the new file, all ensured lazily at the moment of the first real write, never as unconditional `init` bootstrap. `migrate` still never writes to the source agent's own file — kiro's real config keeps its literal forever, and `secrets audit` correctly keeps flagging it; extraction normalizes to a `warning` in onboard's verdict, not `blocked`, with a remediation naming that the source was left untouched. A literal found anywhere other than `staticEnv` (`command`/`url`/`args`/`headers` — no natural name to extract to) still refuses exactly as before. `findLiteralSecret` (`src/adapters/mcpPlan.ts`) now reports which field and dict key a match came from, closing a previously-undocumented spec gap for the unaffected refusal path in the same change. Also found via the same dogfooding, one commit later: extraction alone doesn't make the `${VAR}` reference `mcp sync` writes into an agent's native config actually resolve — that only happens once the name is in the environment that agent's own runtime reads from, which `servers.local.env` alone never reaches. `ensureShellEnvSource` (mirrors `ensureGitignoreEntry`'s idempotent marker-block shape) now appends one generic `set -a; source ...; set +a` pointer block to the user's shell rc (`~/.zshrc`/`~/.bash_profile`/`~/.profile` by `$SHELL`) — never a literal secret line — every real `migrate` run whenever `env_file` is set, retroactively wiring a machine that extracted before this existed. Separately, fixed a real incident this surfaced: no subcommand branch in `src/cli.ts` recognized `--help`/`-h`, so a stray `--help` silently ran the real command instead of printing usage — now short-circuits to usage text before any parsing, for every command. One more round: real-machine research (`mcpConnect.ts`, `jsonMcp.ts`, `tomlSection.ts`) found no `${VAR}` resolution mechanism proven across every consumer for `command`/`url`/`args`, and `headers` unverified for claude-code/kiro specifically — so `command`/`url`/`args`/`headers` literals are now *accepted* into canonical as ordinary config (both at `migrate`'s import boundary and `resolveMcpPlan`'s sync-out boundary), never refused and never faked into an unresolvable reference; `secrets audit` now also scans canonical's own `servers.yaml` for this (`agent: "canonical"`) so it's never silent. `staticEnv` extractions are now named `TRELLIS_<SERVER>_<KEY>` rather than the bare source key, to rule out cross-server or ambient-environment collisions — an already-extracted server (this project's own real `mcp-router`) is recognized as already-migrated by the *value* an existing reference resolves to, not by re-deriving today's naming scheme, so it keeps working under its original bare name without being renamed or re-extracted | P16, P18 |
957
958
 
958
959
  **Cross-machine / cloud sharing — confirmed future direction, not yet
959
960
  scoped as a phase.** Surveyed 2026-09-15 (`docs/research.md`'s "Cross-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-trellis",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "A single source of capability for every coding agent — skills, MCP, subagents, memory, and secret policy, adapted natively into Claude Code, Codex, Kiro, and pi.",
5
5
  "license": "MIT",
6
6
  "author": "Paul Leo",
@@ -28,7 +28,8 @@
28
28
  "typecheck": "tsc --noEmit",
29
29
  "pretest": "node scripts/build-pi-bridge.mjs",
30
30
  "test": "tsx --test \"test/unit/**/*.test.ts\"",
31
- "prepublishOnly": "npm run typecheck && npm run build && npm test"
31
+ "verify-pack": "node scripts/verify-pack.mjs",
32
+ "prepublishOnly": "npm run typecheck && npm run build && npm test && npm run verify-pack"
32
33
  },
33
34
  "engines": {
34
35
  "node": ">=20"