@bluelamp/secret-exec 0.2.1 → 0.3.1

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/README.md CHANGED
@@ -67,3 +67,13 @@ bluelamp-secret put my-db --file ./db-url.txt --url https://db.example.com
67
67
  | 1 | 未ログイン・金庫に無い・権限なし・ポータルに届かない(子は起こさない) |
68
68
  | 2 | 引数の形が違う |
69
69
  | 127 | コマンドを起動できなかった |
70
+
71
+ ## `@bluelamp/secret-exec/handoff`(0.3.0〜・箱 n=2946)
72
+
73
+ 口座の鍵を、起こす子の `CLAUDE_CODE_OAUTH_TOKEN` にだけ渡すコマンドの**組み立て**。monster-orchestrator(配車の席)と scheduler(予約の席)が同じ式で使う。
74
+
75
+ - `buildCredentialHandoff(command, vaultName, bins)` … `command` を「金庫 `vaultName` の鍵を `CLAUDE_CODE_OAUTH_TOKEN` に入れてから exec する」sh の 1 行に包む。値は `bluelamp-secret exec` が子の env に入れ、コマンド置換で受けて仮の名を unset してから exec する=argv・ディスク・ログに出ない。取り出せなければ exit 97(子は起きない)
76
+ - `probeCredential(vaultName, bins)` … 取り出せるかだけを `{ ok, exitCode }` で返す(何も出力しない)
77
+ - `resolveHandoffBins(overrideEnvNames)` … node と bluelamp-secret の実体の絶対パス(launchd の貧しい PATH に依らない)。見つからなければ null
78
+
79
+ ⛔ 値を返す口は無い(テスト P10 が export の一覧を縛る)。外側の `env -u CLAUDE_CODE_OAUTH_TOKEN`(親の継承を切る)は呼び手が付ける。
@@ -0,0 +1,51 @@
1
+ /** 子の env に金庫の値を入れる仮の名(exec 前に unset する)。 */
2
+ export declare const CRED_ENV_NAME = "MO_ACCOUNT_CRED";
3
+ /**
4
+ * 金庫の値から accessToken を取り出して stdout に出す(`node -e` に渡す本文)。
5
+ * 🔐 本文に値は無い(値は env から読む)。stdout はコマンド置換(pipe)で受けるのでディスクに落ちない。
6
+ */
7
+ export declare const EXTRACT_TOKEN_JS: string;
8
+ /** 取り出せるかだけを終了コードで返す本文(0=取り出せる・97=形が違う/空)。⛔ 何も出力しない */
9
+ export declare const PROBE_TOKEN_JS: string;
10
+ /**
11
+ * 値を受けて exec する sh の本文(`$1`=node・`$2`=取り出す本文・`$3`=元の command)。
12
+ * 値は `$(…)` で変数に受けるだけ=argv に出ない。仮の名は exec 前に unset。
13
+ */
14
+ export declare const HANDOFF_SH: string;
15
+ /** 金庫の名前に許す文字(シェルの 1 語にそのまま載るので狭く取る) */
16
+ export declare const VAULT_NAME_RE: RegExp;
17
+ /** POSIX sh の 1 語にクォートする(純関数) */
18
+ export declare function shellQuote(s: string): string;
19
+ export interface HandoffBins {
20
+ /** node の絶対パス(launchd の貧しい PATH に依らない) */
21
+ node: string;
22
+ /** bluelamp-secret の実体(.mjs)の絶対パス。shebang の `env node` に依らないよう node で直接起こす */
23
+ secretExec: string;
24
+ }
25
+ /**
26
+ * `command` を「金庫 `vaultName` の鍵を `CLAUDE_CODE_OAUTH_TOKEN` に入れてから exec する」sh の 1 行に包む(純関数)。
27
+ * 外側の `env -u CLAUDE_CODE_OAUTH_TOKEN`(親の継承を切る多重防御)は呼び手が付ける。
28
+ */
29
+ export declare function buildCredentialHandoff(command: string, vaultName: string, bins: HandoffBins): string;
30
+ /** 取り出せるかだけを確かめる argv(純関数・シェルを通さず spawn する)。 */
31
+ export declare function buildCredentialProbeArgv(vaultName: string, bins: HandoffBins): string[];
32
+ /** 金庫の鍵を取り出せるか(I/O)。⛔ stdout/stderr は捨てる(値は出ないが、bluelamp-secret の案内も呼び手に返さない) */
33
+ export declare function probeCredential(vaultName: string, bins: HandoffBins, timeoutMs?: number): {
34
+ ok: boolean;
35
+ exitCode: number | null;
36
+ };
37
+ export declare const SECRET_EXEC_NAME = "bluelamp-secret";
38
+ /**
39
+ * bluelamp-secret を探す先(純関数・箱 n=2730)。順は
40
+ * 呼び手の上書き(env の値・先頭から)→ /opt/homebrew/bin → /usr/local/bin → PATH の各段 → node と同じ bin(npm の既定 prefix)。
41
+ * Mac の 2 か所を PATH より前に置く=launchd の貧しい PATH でも、Mac で選ばれる実体は変わらない。
42
+ */
43
+ export declare function secretExecCandidates(env: NodeJS.ProcessEnv, execPath: string, overrideEnvNames?: readonly string[]): string[];
44
+ export interface HandoffBinsDeps {
45
+ env: NodeJS.ProcessEnv;
46
+ execPath: string;
47
+ realpath: (p: string) => string;
48
+ npmGlobal: () => string | null;
49
+ }
50
+ /** node / bluelamp-secret の絶対パスを決める。見つからなければ null(呼び手が自分の言葉でエラーにする)。 */
51
+ export declare function resolveHandoffBins(overrideEnvNames?: readonly string[], deps?: Partial<HandoffBinsDeps>): HandoffBins | null;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * 口座の鍵を、起こす子の `CLAUDE_CODE_OAUTH_TOKEN` にだけ渡す組み立て(箱 n=2946・`@bluelamp/secret-exec/handoff`)。
3
+ *
4
+ * 出所: monster-orchestrator の account-pool.ts(箱 n=2079・n=2696・n=2730)から移した。orchestrator(配車の席)と
5
+ * scheduler(予約の席)が同じ式で渡す=二つ目の実装を作らない。
6
+ *
7
+ * ■ 秘密の扱い(⛔ 1 つも破らない)
8
+ * - ここに値は来ない。組み立てるのは「`bluelamp-secret exec <仮の名>=<金庫の名前> -- …` をどう撃つか」だけ。
9
+ * - 値は bluelamp-secret が子の env(仮の名)に入れ、取り出す口(sh + node -e)がコマンド置換で受けて
10
+ * `CLAUDE_CODE_OAUTH_TOKEN` に入れ、仮の名を unset してから exec する=argv・ディスク・ログに出ない。
11
+ * - ⛔ 値を返す関数を足さない(このパッケージの約束・index.ts の冒頭)。
12
+ */
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { spawnSync } from "node:child_process";
16
+ /** 子の env に金庫の値を入れる仮の名(exec 前に unset する)。 */
17
+ export const CRED_ENV_NAME = "MO_ACCOUNT_CRED";
18
+ /** 金庫の値の形を読んで accessToken を `t` に置く本文(JSON `{accessToken}`/JSON の文字列/生の token)。 */
19
+ const READ_TOKEN_JS = `const r=(process.env.${CRED_ENV_NAME}||"").trim();let t;` +
20
+ `try{const o=JSON.parse(r);t=typeof o==="string"?o:o&&typeof o==="object"?o.accessToken:undefined}catch{t=/^[^\\s{}"]+$/.test(r)?r:undefined}`;
21
+ /**
22
+ * 金庫の値から accessToken を取り出して stdout に出す(`node -e` に渡す本文)。
23
+ * 🔐 本文に値は無い(値は env から読む)。stdout はコマンド置換(pipe)で受けるのでディスクに落ちない。
24
+ */
25
+ export const EXTRACT_TOKEN_JS = READ_TOKEN_JS +
26
+ `if(typeof t!=="string"||t.length===0){process.stderr.write("bluelamp: 口座の資格を取り出せない\\n");process.exit(97)}` +
27
+ `process.stdout.write(t)`;
28
+ /** 取り出せるかだけを終了コードで返す本文(0=取り出せる・97=形が違う/空)。⛔ 何も出力しない */
29
+ export const PROBE_TOKEN_JS = READ_TOKEN_JS + `process.exit(typeof t==="string"&&t.length>0?0:97)`;
30
+ /**
31
+ * 値を受けて exec する sh の本文(`$1`=node・`$2`=取り出す本文・`$3`=元の command)。
32
+ * 値は `$(…)` で変数に受けるだけ=argv に出ない。仮の名は exec 前に unset。
33
+ */
34
+ export const HANDOFF_SH = `T=$("$1" -e "$2") || exit 97; unset ${CRED_ENV_NAME}; ` +
35
+ `CLAUDE_CODE_OAUTH_TOKEN=$T; export CLAUDE_CODE_OAUTH_TOKEN; unset T; exec /bin/sh -c "$3"`;
36
+ /** 金庫の名前に許す文字(シェルの 1 語にそのまま載るので狭く取る) */
37
+ export const VAULT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
38
+ /** POSIX sh の 1 語にクォートする(純関数) */
39
+ export function shellQuote(s) {
40
+ return `'${s.replace(/'/g, `'\\''`)}'`;
41
+ }
42
+ /**
43
+ * `command` を「金庫 `vaultName` の鍵を `CLAUDE_CODE_OAUTH_TOKEN` に入れてから exec する」sh の 1 行に包む(純関数)。
44
+ * 外側の `env -u CLAUDE_CODE_OAUTH_TOKEN`(親の継承を切る多重防御)は呼び手が付ける。
45
+ */
46
+ export function buildCredentialHandoff(command, vaultName, bins) {
47
+ if (!VAULT_NAME_RE.test(vaultName))
48
+ throw new Error(`金庫の名前の形が不正: ${JSON.stringify(vaultName)}`);
49
+ return [
50
+ shellQuote(bins.node),
51
+ shellQuote(bins.secretExec),
52
+ "exec",
53
+ `${CRED_ENV_NAME}=${vaultName}`,
54
+ "--",
55
+ "/bin/sh",
56
+ "-c",
57
+ shellQuote(HANDOFF_SH),
58
+ "mo-account",
59
+ shellQuote(bins.node),
60
+ shellQuote(EXTRACT_TOKEN_JS),
61
+ shellQuote(command),
62
+ ].join(" ");
63
+ }
64
+ /** 取り出せるかだけを確かめる argv(純関数・シェルを通さず spawn する)。 */
65
+ export function buildCredentialProbeArgv(vaultName, bins) {
66
+ if (!VAULT_NAME_RE.test(vaultName))
67
+ throw new Error(`金庫の名前の形が不正: ${JSON.stringify(vaultName)}`);
68
+ return [bins.secretExec, "exec", `${CRED_ENV_NAME}=${vaultName}`, "--", bins.node, "-e", PROBE_TOKEN_JS];
69
+ }
70
+ /** 金庫の鍵を取り出せるか(I/O)。⛔ stdout/stderr は捨てる(値は出ないが、bluelamp-secret の案内も呼び手に返さない) */
71
+ export function probeCredential(vaultName, bins, timeoutMs = 15_000) {
72
+ // bluelamp-secret(.mjs)は shebang の `env node` に依らず node で直接起こす
73
+ const r = spawnSync(bins.node, buildCredentialProbeArgv(vaultName, bins), { stdio: "ignore", timeout: timeoutMs });
74
+ return { ok: r.status === 0, exitCode: r.status };
75
+ }
76
+ /* ============================== bluelamp-secret の置き場 ============================== */
77
+ export const SECRET_EXEC_NAME = "bluelamp-secret";
78
+ /**
79
+ * bluelamp-secret を探す先(純関数・箱 n=2730)。順は
80
+ * 呼び手の上書き(env の値・先頭から)→ /opt/homebrew/bin → /usr/local/bin → PATH の各段 → node と同じ bin(npm の既定 prefix)。
81
+ * Mac の 2 か所を PATH より前に置く=launchd の貧しい PATH でも、Mac で選ばれる実体は変わらない。
82
+ */
83
+ export function secretExecCandidates(env, execPath, overrideEnvNames = []) {
84
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter((d) => path.isAbsolute(d));
85
+ const list = [
86
+ // 上書きは絶対パスだけ(相対は cwd で解決され、起こした場所で別の実体を掴む・CTO H-5 指摘②)
87
+ ...overrideEnvNames.map((n) => env[n]).filter((p) => typeof p === "string" && path.isAbsolute(p)),
88
+ `/opt/homebrew/bin/${SECRET_EXEC_NAME}`,
89
+ `/usr/local/bin/${SECRET_EXEC_NAME}`,
90
+ ...dirs.map((d) => path.join(d, SECRET_EXEC_NAME)),
91
+ path.join(path.dirname(execPath), SECRET_EXEC_NAME),
92
+ ].filter((p) => typeof p === "string" && p.length > 0);
93
+ return [...new Set(list)];
94
+ }
95
+ /** `npm prefix -g`/bin の bluelamp-secret(npm の prefix を ~/.npmrc で変えた機体向け)。npm が無い・遅いときは null。 */
96
+ function npmGlobalSecretExec() {
97
+ const r = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 5_000 });
98
+ const prefix = r.status === 0 ? r.stdout.trim() : "";
99
+ return path.isAbsolute(prefix) ? path.join(prefix, "bin", SECRET_EXEC_NAME) : null;
100
+ }
101
+ /** node / bluelamp-secret の絶対パスを決める。見つからなければ null(呼び手が自分の言葉でエラーにする)。 */
102
+ export function resolveHandoffBins(overrideEnvNames = [], deps) {
103
+ const d = {
104
+ env: process.env,
105
+ execPath: process.execPath,
106
+ realpath: fs.realpathSync,
107
+ npmGlobal: npmGlobalSecretExec,
108
+ ...deps,
109
+ };
110
+ const tryOne = (c) => {
111
+ try {
112
+ return { node: d.execPath, secretExec: d.realpath(c) };
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ };
118
+ for (const c of secretExecCandidates(d.env, d.execPath, overrideEnvNames)) {
119
+ const b = tryOne(c);
120
+ if (b)
121
+ return b;
122
+ }
123
+ const g = d.npmGlobal();
124
+ return g ? tryOne(g) : null;
125
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @bluelamp/secret-exec — `bluelamp-secret exec`(箱 n=2018 設計④′)と `bluelamp-secret put`(箱 n=2498・下の put の節)
3
+ *
4
+ * 🔒 これは「値を返さない口」である(実装より先に書いた線・README の冒頭と同じ)。
5
+ * 取れるのは「金庫にその名前が在るか無いか」と「値を使えるか(子プロセスが env で受けて走れたか=終了コード)」だけ。
6
+ * ⛔ 値を標準出力・標準エラー・戻り値・ファイル・argv・ログ・エラーの文言に出す口を作らない。
7
+ * ⛔ 値を返す関数を export しない(呼び手に値を渡す API を足したくなったら、それはこのパッケージの仕事ではない)。
8
+ */
9
+ /**
10
+ * 🕳 塞いでいる穴: MCP の `get_secret` は値を返り値にしていたため、値が会話の転写に残った。
11
+ * その口を閉じると、値を Bash で使っていた手順(21 日で 235 回)が壊れる。⇒ 値を「見せずに使う」口をここに置く。
12
+ *
13
+ * 🔑 値の通り道は「金庫の HTTP 応答 → 子の env」の 1 本だけ。
14
+ * ⛔ HTTP の本文・fetch の例外をそのまま出さない(本文は値を、例外は要求を引用しうる)。
15
+ * 失敗は「どの ENV 名・どの金庫の名前が・なぜ」だけを返す。
16
+ * 🔑 パッケージの外へ出す API は無い(package.json の exports は package.json だけ)。
17
+ * 下の export は同じパッケージの bin と unit テストのためのもの。値を返す関数は export しない(`extractSecretValue` は非公開)。`runSecretExec` の戻り値は終了コードだけ。
18
+ * 🔑 使用の記録: `GET /vault/:service` は backend 側で監査 `get`(誰が・どの秘密を)を 1 行書く。
19
+ *
20
+ * 判定と組み立ては純粋関数(unit で直接撃つ)、網と子プロセスは `runSecretExec` の deps に寄せる。
21
+ */
22
+ /** ENV 名として受ける形。⛔ これ以外(小文字・記号・先頭数字)は受けない */
23
+ export declare const ENV_NAME_PATTERN: RegExp;
24
+ /** 既定のポータル API(bluelamp CLI の portal-api-client・TokenManager と同じ値) */
25
+ export declare const DEFAULT_PORTAL_API_BASE_URL = "https://api.motohi.jp/api/bluelamp";
26
+ export declare const USAGE: string;
27
+ export interface SecretBinding {
28
+ env: string;
29
+ name: string;
30
+ }
31
+ export type ParseResult = {
32
+ ok: true;
33
+ bindings: SecretBinding[];
34
+ command: string;
35
+ args: string[];
36
+ } | {
37
+ ok: false;
38
+ help: boolean;
39
+ error: string;
40
+ };
41
+ /** 引数を解析する(`process.argv.slice(2)` を受ける)。値はここに来ない=金庫の名前だけ */
42
+ export declare function parseExecArgs(argv: readonly string[]): ParseResult;
43
+ /** 基底 URL(BLUELAMP_PORTAL_API_BASE_URL > PORTAL_URL > 既定。portal-api-client.cjs と同じ順) */
44
+ export declare function resolvePortalApiBaseUrl(env: Readonly<Record<string, string | undefined>>): string;
45
+ /**
46
+ * 金庫の 1 件を引く URL。
47
+ * 🔑 vault の sub-app は内部で `/api/vault/...` に経路を切るが、bluelamp module ごと `/api/bluelamp` に
48
+ * 載っているので外から見える経路は `<base>/vault/...`(base は /api/bluelamp まで含む)。
49
+ * 🔬 実測(無認証): `/api/bluelamp/api/vault/x` → 404/`/api/bluelamp/vault/x` → 401。
50
+ * mcp-list/bluelamp/playwright/src/fillSecret.ts の VAULT_FILL_PATH と同じ結論。
51
+ */
52
+ export declare function buildVaultUrl(baseUrl: string, name: string): string;
53
+ /** 子の env を合成する(親を書き換えない) */
54
+ export declare function composeChildEnv(parentEnv: Readonly<Record<string, string | undefined>>, values: Readonly<Record<string, string>>): Record<string, string>;
55
+ /** HTTP の状態を理由の文言にする(⛔ 本文は使わない) */
56
+ export declare function describeVaultStatus(status: number): string;
57
+ /** 子の終わり方を終了コードにする(シグナルは 128+番号=シェルと同じ) */
58
+ export declare function exitCodeOf(code: number | null, signal: string | null, signalNumbers: Readonly<Record<string, number>>): number;
59
+ interface FetchResponseLike {
60
+ ok: boolean;
61
+ status: number;
62
+ json(): Promise<unknown>;
63
+ }
64
+ type FetchLike = (url: string, init: {
65
+ method: string;
66
+ headers: Record<string, string>;
67
+ body?: string;
68
+ }) => Promise<FetchResponseLike>;
69
+ interface ChildLike {
70
+ on(event: 'exit', cb: (code: number | null, signal: string | null) => void): unknown;
71
+ on(event: 'error', cb: (err: {
72
+ code?: string;
73
+ }) => void): unknown;
74
+ kill(signal?: string): unknown;
75
+ }
76
+ export interface SecretExecDeps {
77
+ env: Readonly<Record<string, string | undefined>>;
78
+ loadToken: () => string | null;
79
+ fetch: FetchLike;
80
+ spawn: (command: string, args: string[], options: {
81
+ stdio: 'inherit';
82
+ shell: false;
83
+ env: Record<string, string>;
84
+ }) => ChildLike;
85
+ stderr: (text: string) => void;
86
+ signalNumbers: Readonly<Record<string, number>>;
87
+ /** 親が受けたシグナルを子へ回す口(無ければ回さない)。戻り値は解除関数 */
88
+ onParentSignal?: (forward: (signal: string) => void) => () => void;
89
+ }
90
+ /** 実行する。戻り値は終了コード */
91
+ export declare function runSecretExec(argv: readonly string[], deps: SecretExecDeps): Promise<number>;
92
+ /**
93
+ * 🔒 exec と対になる「値を会話に出さない書き込みの口」。
94
+ * 値の通り道は「標準入力(か --file のファイル)→ 金庫への POST 本文」の 1 本だけ。
95
+ * ⛔ 値を引数で受けない(argv はプロセス一覧と転写に残る)。⛔ 値・HTTP の本文を出力に出さない。
96
+ * 🔑 金庫の POST は同じ名前を黙って上書きする(🔬 2026-10-05 本番で実測)。⇒ --force が無ければ
97
+ * 先に一覧(値を含まない)で「自分の金庫に同じ名前が在るか」を見て止める。共有されてきた同名は
98
+ * 自分の金庫には無いので止めない(書けば自分の側に別の 1 件ができ、exec は自分の側を先に引く)。
99
+ * 🔑 保存の後に読み戻して一致を確かめる(読み戻しは exec と同じ GET=監査に get が 1 行残る)。
100
+ */
101
+ export interface PutOptions {
102
+ name: string;
103
+ note?: string;
104
+ username?: string;
105
+ /** --url から導いた origin(scheme+host+port)。金庫の列は bound_origins */
106
+ origin?: string;
107
+ file?: string;
108
+ force: boolean;
109
+ }
110
+ export type PutParseResult = {
111
+ ok: true;
112
+ options: PutOptions;
113
+ } | {
114
+ ok: false;
115
+ help: boolean;
116
+ error: string;
117
+ };
118
+ /** --url を origin に畳む(http/https だけ受ける)。畳めなければ null */
119
+ export declare function deriveOrigin(raw: string): string | null;
120
+ /**
121
+ * put の引数を解析する(`process.argv.slice(2)` を受ける)。
122
+ * ⛔ 失敗の文言に、知らない引数や余分な位置引数の中身を引用しない(値を引数に書き間違えた場合に値が出るため)。
123
+ */
124
+ export declare function parsePutArgs(argv: readonly string[]): PutParseResult;
125
+ export interface SecretPutDeps {
126
+ env: Readonly<Record<string, string | undefined>>;
127
+ loadToken: () => string | null;
128
+ fetch: FetchLike;
129
+ /** 標準入力が端末か */
130
+ stdinIsTTY: () => boolean;
131
+ /** 標準入力を最後まで読む(⛔ 失敗時の例外の文言は出さない) */
132
+ readStdin: () => Promise<string>;
133
+ readFile: (path: string) => Promise<string>;
134
+ stderr: (text: string) => void;
135
+ }
136
+ /** put を実行する。戻り値は終了コード(0 保存して一致/1 失敗/2 引数・入力の形/3 既に在る) */
137
+ export declare function runSecretPut(argv: readonly string[], deps: SecretPutDeps): Promise<number>;
138
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bluelamp/secret-exec",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "金庫の値を子プロセスの環境変数にだけ入れてコマンドを実行し(exec)、標準入力から金庫へ入れる(put)。値を画面・引数・ログに出さない口",
5
5
  "keywords": [
6
6
  "bluelamp",
@@ -11,7 +11,11 @@
11
11
  "author": "BlueLamp <dev@bluelamp.app>",
12
12
  "type": "module",
13
13
  "exports": {
14
- "./package.json": "./package.json"
14
+ "./package.json": "./package.json",
15
+ "./handoff": {
16
+ "types": "./dist/handoff.d.ts",
17
+ "default": "./dist/handoff.js"
18
+ }
15
19
  },
16
20
  "bin": {
17
21
  "bluelamp-secret": "./bin/bluelamp-secret.mjs"