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.
- package/dist/adapters/mcpPlan.d.ts +20 -1
- package/dist/adapters/mcpPlan.js +29 -7
- package/dist/cli.js +9 -0
- package/dist/commands/migrate.d.ts +21 -2
- package/dist/commands/migrate.js +169 -16
- package/dist/commands/onboard.js +6 -2
- package/dist/commands/onboardVerdict.js +20 -7
- package/dist/commands/secretsAudit.d.ts +21 -12
- package/dist/commands/secretsAudit.js +39 -10
- package/dist/core/canonical.d.ts +46 -0
- package/dist/core/canonical.js +88 -4
- package/dist/lib/secretEnv.d.ts +16 -0
- package/dist/lib/secretEnv.js +27 -1
- package/dist/lib/terminalPicker.d.ts +21 -0
- package/dist/lib/terminalPicker.js +65 -10
- package/dist/pi-bridge/bundle.js +18 -14
- package/docs/getting-started.md +75 -0
- package/docs/roadmap.md +1 -0
- package/package.json +3 -2
|
@@ -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):
|
|
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;
|
package/dist/adapters/mcpPlan.js
CHANGED
|
@@ -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 = [
|
|
77
|
-
|
|
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(
|
|
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
|
-
|
|
153
|
-
|
|
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 (${
|
|
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;
|
package/dist/commands/migrate.js
CHANGED
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
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
|
|
128
|
-
remediation: `
|
|
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
|
|
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,
|
|
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) {
|
package/dist/commands/onboard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
verdict
|
|
50
|
-
|
|
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
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (trellis-
|
|
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
|
|
27
|
-
|
|
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
|
|
4
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* (trellis-
|
|
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 = {}) {
|
package/dist/core/canonical.d.ts
CHANGED
|
@@ -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
|
package/dist/core/canonical.js
CHANGED
|
@@ -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". */
|
package/dist/lib/secretEnv.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/secretEnv.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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}[
|
|
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
|
|
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,
|
|
122
|
-
|
|
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
|
|
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,
|
|
164
|
-
|
|
218
|
+
renderRows(output, rows, physicalLines);
|
|
219
|
+
physicalLines = totalPhysicalLines(rows, columns);
|
|
165
220
|
};
|
|
166
221
|
draw();
|
|
167
222
|
const onData = (chunk) => {
|
package/dist/pi-bridge/bundle.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 = [
|
|
15383
|
-
|
|
15384
|
-
|
|
15385
|
-
|
|
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(
|
|
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
|
|
15436
|
-
if (
|
|
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 (${
|
|
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;
|
package/docs/getting-started.md
CHANGED
|
@@ -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.
|
|
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
|
-
"
|
|
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"
|