i-repo 2.14.0 → 2.16.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.
@@ -44,6 +44,13 @@ function dryRunArgs(dryRun) {
44
44
  async function runSink(sink, archivePath, ctx, resolver) {
45
45
  const base = { id: sink.id, plugin: sink.plugin, kind: sink.kind, skipped: false, receipts: [] };
46
46
  const extraArgs = sink.args ?? [];
47
+ // per-sink secret env(#94): deliver プロセスの env から名指しの変数だけを取り出し、
48
+ // この sink のプロセスにだけ注入する(argv 非掲載・他 sink へ漏れない)。
49
+ const extraEnv = resolveSecretEnv(sink.secretEnv);
50
+ // 転送した secret の**値**を receipt から伏せる(#94/#5)。プラグインが受領 secret を
51
+ // receipt へ echo しても、deliver-run/pluginReceipts に平文の鍵が残らないようにする。
52
+ const secretValues = Object.values(extraEnv ?? {});
53
+ const redact = (receipts) => redactSecretValues(receipts, secretValues);
47
54
  if (sink.kind === "stream") {
48
55
  const manifest = join(archivePath, "manifests", "reports.ndjson");
49
56
  let stdin = createReadStream(manifest);
@@ -51,33 +58,48 @@ async function runSink(sink, archivePath, ctx, resolver) {
51
58
  // resolver は orchestrator が bundle 着地後に組む(実パス or null)。
52
59
  stdin = stdin.pipe(createLocatorEnrich(resolver ?? (() => null)));
53
60
  }
54
- const cap = await capturePlugin(sink.plugin, [...extraArgs], ctx, { stdin });
61
+ const cap = await capturePlugin(sink.plugin, [...extraArgs], ctx, { stdin, extraEnv });
55
62
  if (!cap)
56
63
  return { ...base, failure: `plugin "i-repo-${sink.plugin}" not found` };
57
- return {
58
- ...base,
59
- code: cap.code,
60
- receipts: cap.receipts,
61
- ...(sinkFailure(cap.code, cap.receipts)),
62
- };
64
+ const receipts = redact(cap.receipts);
65
+ return { ...base, code: cap.code, receipts, ...(sinkFailure(cap.code, receipts)) };
63
66
  }
64
67
  // bundle sink: archive dir を位置引数で渡す(stdin 無し)。
65
- const cap = await capturePlugin(sink.plugin, [sink.subcommand, archivePath, ...extraArgs], ctx);
68
+ const cap = await capturePlugin(sink.plugin, [sink.subcommand, archivePath, ...extraArgs], ctx, { extraEnv });
66
69
  if (!cap)
67
70
  return { ...base, failure: `plugin "i-repo-${sink.plugin}" not found` };
71
+ const receipts = redact(cap.receipts);
68
72
  // bundle sink の receipt が焼いた canonical な store location(dest)/ storeKind を
69
73
  // sink へ載せる(#89)。DC が catalog の dataset identity・store.location に使うため、
70
74
  // **delivered(verify 成功)した sink のみ**に載せる — 失敗 sink の receipt も
71
75
  // dest を持つが、未着地の宛先を identity として広告すると DC が phantom dataset を作る。
72
- const verifiedOk = receiptVerifiedOk(cap.receipts);
76
+ const verifiedOk = receiptVerifiedOk(receipts);
73
77
  return {
74
78
  ...base,
75
79
  code: cap.code,
76
- receipts: cap.receipts,
77
- ...(verifiedOk ? sinkDestFromReceipts(cap.receipts) : {}),
78
- ...(sinkFailure(cap.code, cap.receipts)),
80
+ receipts,
81
+ ...(verifiedOk ? sinkDestFromReceipts(receipts) : {}),
82
+ ...(sinkFailure(cap.code, receipts)),
79
83
  };
80
84
  }
85
+ /**
86
+ * 転送 secret の値を receipt 群から伏せる(部分一致も置換・#94)。
87
+ * 短い値は部分一致で**無関係なフィールド(canonical dest 等)を巻き込んで壊す**ため除外する
88
+ * (host が AWS_REGION 等を誤って secretEnv に入れた場合の collision を防ぐ)。実クラウド資格情報は
89
+ * 十分長い(AWS secret key=40・access key id=20・GCP creds=パス)ので閾値で取りこぼさない。
90
+ */
91
+ const SECRET_REDACT_MIN_LEN = 8;
92
+ function redactSecretValues(receipts, values) {
93
+ const secrets = values.filter((v) => typeof v === "string" && v.length >= SECRET_REDACT_MIN_LEN);
94
+ if (!secrets.length)
95
+ return receipts;
96
+ const scrub = (s) => secrets.reduce((acc, sec) => acc.split(sec).join("***"), s);
97
+ const walk = (x) => typeof x === "string" ? scrub(x)
98
+ : Array.isArray(x) ? x.map(walk)
99
+ : x && typeof x === "object" ? Object.fromEntries(Object.entries(x).map(([k, v]) => [k, walk(v)]))
100
+ : x;
101
+ return receipts.map((r) => walk(r));
102
+ }
81
103
  /** push receipt が焼いた canonical `dest` / `storeKind` を拾う(#89・bundle sink)。 */
82
104
  function sinkDestFromReceipts(receipts) {
83
105
  const out = {};
@@ -99,6 +121,32 @@ function locatorSourceId(sink, bundleIds) {
99
121
  return sink.locatorFrom;
100
122
  return bundleIds.length === 1 ? bundleIds[0] : undefined;
101
123
  }
124
+ /**
125
+ * sink.secretEnv(env 名リスト)を deliver プロセスの env から解決する(#94)。
126
+ * 値は host が deliver プロセスへ設定したもの。spec に値は持たせない(disk 非掲載)。
127
+ * 未設定の名前は黙って飛ばす(host が用意していない=その sink は認証できず失敗するだけ)。
128
+ *
129
+ * 照合は **大文字小文字を無視**する(dispatch の env 取り扱い=parsePassEnv が env 名を
130
+ * 大文字化して照合するのに合わせる)。POSIX env は本来 case-sensitive だが資格情報は慣習的に
131
+ * 大文字(AWS_* や GOOGLE_… 等)で、spec 著者の lowercase 取り違えが**無診断で認証失敗**するのを防ぐ。
132
+ * 転送は **実在 env キー名**で行う(SDK が読む正準名を保つ)。
133
+ */
134
+ function resolveSecretEnv(names) {
135
+ if (!names || names.length === 0)
136
+ return undefined;
137
+ const index = new Map(); // UPPER(name) -> [realName, value]
138
+ for (const [k, v] of Object.entries(process.env)) {
139
+ if (typeof v === "string")
140
+ index.set(k.toUpperCase(), [k, v]);
141
+ }
142
+ const out = {};
143
+ for (const n of names) {
144
+ const hit = index.get(n.toUpperCase());
145
+ if (hit)
146
+ out[hit[0]] = hit[1];
147
+ }
148
+ return Object.keys(out).length ? out : undefined;
149
+ }
102
150
  function sinkFailure(code, receipts) {
103
151
  if (code !== 0)
104
152
  return { failure: `exit ${code}` };
@@ -13,6 +13,13 @@ export interface SinkSpec {
13
13
  /** locator をどの bundle sink から組むか(bundle が複数のとき必須)。 */
14
14
  locatorFrom?: string;
15
15
  skipIfDelivered?: boolean;
16
+ /**
17
+ * この sink のプロセスへ転送する env 変数名のリスト(#94)。値は deliver プロセスの
18
+ * env(host が設定)から取り、この sink だけに注入する(argv 非掲載・他 sink へ漏れない)。
19
+ * 無人クラウド認証(AWS_* や GOOGLE_APPLICATION_CREDENTIALS 等)を fan-out 経由で渡す用。
20
+ * プラグインの envVar 宣言に依存しない(任意の第三者プラグインで動く)。
21
+ */
22
+ secretEnv?: string[];
16
23
  }
17
24
  export interface ArchiveSpec {
18
25
  plugin: string;
@@ -7,6 +7,9 @@
7
7
  */
8
8
  import { readFileSync } from "node:fs";
9
9
  import { ValidationError } from "../../core/errors.js";
10
+ import { isForwardableSecretEnvName } from "../../plugins/dispatch.js";
11
+ /** env 変数名として妥当か(POSIX 風・注入/取り違え防止)。 */
12
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
10
13
  function isObject(v) {
11
14
  return v !== null && typeof v === "object" && !Array.isArray(v);
12
15
  }
@@ -73,6 +76,17 @@ export function validateDeliverSpec(value) {
73
76
  bundleIds.add(s.id);
74
77
  }
75
78
  }
79
+ if ("secretEnv" in s) {
80
+ if (!Array.isArray(s.secretEnv) || !s.secretEnv.every((n) => typeof n === "string" && ENV_NAME_RE.test(n))) {
81
+ p.push(`sinks[${i}].secretEnv must be an array of env var names ([A-Za-z_][A-Za-z0-9_]*)`);
82
+ }
83
+ else {
84
+ // contract 所有(IREPO_*)・ローダ/実行系乗っ取り(LD_*/DYLD_*/PATH/NODE_OPTIONS 等)は禁止。
85
+ const bad = s.secretEnv.filter((n) => !isForwardableSecretEnvName(n));
86
+ if (bad.length)
87
+ p.push(`sinks[${i}].secretEnv must not include protected/dangerous names: ${bad.join(", ")}`);
88
+ }
89
+ }
76
90
  });
77
91
  // locatorFrom の整合(§9.4): enrichLocator の stream sink が複数 bundle 下で
78
92
  // どの bundle を使うか曖昧にしない。順序依存の暗黙選択は禁止。
@@ -10,6 +10,13 @@ export interface CaptureOptions {
10
10
  timeoutSec?: number;
11
11
  /** Run-level abort — on abort the child is SIGTERM'd (then SIGKILL after grace). */
12
12
  signal?: AbortSignal;
13
+ /**
14
+ * Extra env merged onto the allow-listed plugin env, AFTER buildPluginEnv
15
+ * (so it can re-add vars the allow-list would otherwise scrub). Used by
16
+ * `deliver` to forward per-sink secret env (gemba-deliver `secretEnv`, #94)
17
+ * into this sink's process only — never into argv, never to sibling sinks.
18
+ */
19
+ extraEnv?: Record<string, string>;
13
20
  }
14
21
  export interface CaptureResult {
15
22
  /** Exit code; signal deaths map to 128+n (SIGNUM), matching dispatchPlugin. */
@@ -15,7 +15,7 @@
15
15
  * identically whether invoked directly or under deliver.
16
16
  */
17
17
  import { spawn } from "node:child_process";
18
- import { buildPluginEnv, prepareSpawn, GRACE_MS, SIGNUM, } from "./dispatch.js";
18
+ import { buildPluginEnv, isForwardableSecretEnvName, prepareSpawn, GRACE_MS, SIGNUM, } from "./dispatch.js";
19
19
  import { parseLines } from "./verify.js";
20
20
  /**
21
21
  * Run a plugin capturing its stdout. Returns null when no `i-repo-<name>`
@@ -32,6 +32,18 @@ export async function capturePlugin(name, args, ctx, opts = {}) {
32
32
  }
33
33
  const { spawnBin, spawnArgs, windowsVerbatim } = prep.plan;
34
34
  const env = buildPluginEnv(ctx);
35
+ // per-sink の追加 env(#94)。allow-list の後に重ねる=この sink のプロセス限定で
36
+ // host 指定の secret env を復活させる(他 sink・argv には出ない)。ただし
37
+ // **buildPluginEnv の trust 境界は迂回させない**: 契約所有(IREPO_*)・ローダ/実行系
38
+ // 乗っ取り(LD_*/DYLD_*/PATH/NODE_OPTIONS 等)の名前は defense-in-depth で弾く。
39
+ if (opts.extraEnv) {
40
+ for (const [k, v] of Object.entries(opts.extraEnv)) {
41
+ if (isForwardableSecretEnvName(k))
42
+ env[k] = v;
43
+ else
44
+ process.stderr.write(`deliver: refusing to forward protected env "${k}" to a sink\n`);
45
+ }
46
+ }
35
47
  return await new Promise((resolve) => {
36
48
  const child = spawn(spawnBin, spawnArgs, {
37
49
  // stdout piped (we capture receipts); stderr inherited (progress flows to
@@ -20,6 +20,7 @@ export interface PluginContext {
20
20
  */
21
21
  save?: string;
22
22
  }
23
+ export declare function isForwardableSecretEnvName(name: string): boolean;
23
24
  /** Plugin names are restricted to a safe charset to prevent path escaping. */
24
25
  export declare function isValidPluginName(name: string): boolean;
25
26
  /**
@@ -18,6 +18,26 @@ import { fingerprintEndpoint } from "../config/endpoint-fingerprint.js";
18
18
  import { resolveSourceMachine } from "../config/provenance.js";
19
19
  /** Prefix every plugin executable must carry. */
20
20
  export const PLUGIN_PREFIX = "i-repo-";
21
+ /**
22
+ * deliver の per-sink `secretEnv`(#94)で sink へ転送してよい env 名か。
23
+ * buildPluginEnv の trust 境界を secretEnv で迂回させないための denylist:
24
+ * - `IREPO_*`: 契約/来歴は CLI が権威的に設定する正本(spoof・clobber 禁止。IREPO_PASSWORD 含む)。
25
+ * - `LD_*` / `DYLD_*`: ELF/Mach-O ローダ注入(任意コード実行)。
26
+ * - `PATH`/`NODE_OPTIONS`/`BASH_ENV`/`ENV`/`IFS`/`SHELL`/`PYTHON*PATH` 等: 実行系の乗っ取りベクタ。
27
+ * 大文字小文字無視で判定(case で denylist をすり抜けさせない)。
28
+ */
29
+ const SECRET_ENV_DENY_EXACT = new Set([
30
+ "PATH", "NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", "BASH_ENV", "ENV", "IFS",
31
+ "SHELL", "PYTHONPATH", "PYTHONSTARTUP", "PERL5LIB", "PERL5OPT", "RUBYOPT", "GEM_PATH",
32
+ ]);
33
+ export function isForwardableSecretEnvName(name) {
34
+ const u = String(name).toUpperCase();
35
+ if (u.startsWith("IREPO_"))
36
+ return false;
37
+ if (u.startsWith("LD_") || u.startsWith("DYLD_"))
38
+ return false;
39
+ return !SECRET_ENV_DENY_EXACT.has(u);
40
+ }
21
41
  const isWin = process.platform === "win32";
22
42
  /** Plugin names are restricted to a safe charset to prevent path escaping. */
23
43
  export function isValidPluginName(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i-repo",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "Modern CLI for ConMas i-Reporter - Built for humans and AI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -44,7 +44,7 @@ async function loadBuiltins() {
44
44
  }
45
45
 
46
46
  const PLUGIN = "i-repo-archive";
47
- const VERSION = "0.6.2";
47
+ const VERSION = "0.6.3";
48
48
  const PLUGIN_API = "1";
49
49
  const defaultArchiveRoot = () => join(homedir(), ".i-repo", "archives");
50
50
 
@@ -179,6 +179,19 @@ if (arg0 === "--plugin-schema") {
179
179
  { name: "--cleanup", type: "boolean", subcommand: "push-s3", destructive: true,
180
180
  label: "Clean up local archive",
181
181
  description: "Delete the local archive directory after the upload is verified" },
182
+ // push-s3 無人認証(#92・push-azure と同作法): 値は argv ではなく env で aws に渡す。
183
+ { name: "--access-key-id", type: "string", subcommand: "push-s3", envVar: "AWS_ACCESS_KEY_ID",
184
+ label: "AWS アクセスキーID(任意・無人用)",
185
+ description: "IAM アクセスキーID。値は環境変数 AWS_ACCESS_KEY_ID で aws に渡す(対話 aws sso login 不要で無人化)" },
186
+ { name: "--secret-access-key", type: "string", subcommand: "push-s3", secret: true, envVar: "AWS_SECRET_ACCESS_KEY",
187
+ label: "AWS シークレットアクセスキー(任意・無人用)",
188
+ description: "IAM シークレットアクセスキー。値は argv に出さず環境変数 AWS_SECRET_ACCESS_KEY で aws に渡す" },
189
+ { name: "--profile", type: "string", subcommand: "push-s3", envVar: "AWS_PROFILE",
190
+ label: "AWS プロファイル(任意)",
191
+ description: "named profile(~/.aws 構成)。値は環境変数 AWS_PROFILE で aws に渡す" },
192
+ { name: "--region", type: "string", subcommand: "push-s3", envVar: "AWS_REGION",
193
+ label: "AWS リージョン(任意)",
194
+ description: "リージョン。値は環境変数 AWS_REGION で aws に渡す" },
182
195
  // push-gcs
183
196
  { name: "--to", type: "string", required: true, subcommand: "push-gcs",
184
197
  label: "GCS destination", placeholder: "gs://bucket/prefix",
@@ -190,6 +203,10 @@ if (arg0 === "--plugin-schema") {
190
203
  { name: "--cleanup", type: "boolean", subcommand: "push-gcs", destructive: true,
191
204
  label: "Clean up local archive",
192
205
  description: "Delete the local archive directory after the upload is verified" },
206
+ // push-gcs 無人認証(#92): SA 鍵 JSON の**パス**を env(GOOGLE_APPLICATION_CREDENTIALS)で gcloud に渡す。
207
+ { name: "--gcp-credentials", type: "string", subcommand: "push-gcs", envVar: "GOOGLE_APPLICATION_CREDENTIALS",
208
+ label: "GCP サービスアカウント鍵のパス(任意・無人用)",
209
+ description: "サービスアカウント鍵 JSON のファイルパス(鍵そのものではない)。env GOOGLE_APPLICATION_CREDENTIALS で受け、プラグインが gcloud CLI 用に CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE へも複写して無人化(対話 gcloud auth login 不要)" },
193
210
  // push-azure
194
211
  { name: "--to", type: "string", required: true, subcommand: "push-azure",
195
212
  label: "Azure Blob destination", placeholder: "https://<account>.blob.core.windows.net/<container>/<prefix>",
@@ -222,8 +239,13 @@ if (arg0 === "--plugin-schema") {
222
239
  // pull-*(取り戻し・host 管理。GUI 配信フォームには出ない=mode:read。Rust が固定引数で渡す)
223
240
  { name: "--from", type: "string", subcommand: "pull-s3", description: "取得元 s3://bucket/prefix(着地した bundle のルート)" },
224
241
  { name: "--out", type: "string", subcommand: "pull-s3", description: "取得先ローカルディレクトリ" },
242
+ { name: "--access-key-id", type: "string", subcommand: "pull-s3", envVar: "AWS_ACCESS_KEY_ID", description: "無人取り戻し用 IAM アクセスキーID(env AWS_ACCESS_KEY_ID)" },
243
+ { name: "--secret-access-key", type: "string", subcommand: "pull-s3", secret: true, envVar: "AWS_SECRET_ACCESS_KEY", description: "無人取り戻し用 IAM シークレットキー(env のみ・argv 非掲載)" },
244
+ { name: "--profile", type: "string", subcommand: "pull-s3", envVar: "AWS_PROFILE", description: "AWS named profile(env AWS_PROFILE)" },
245
+ { name: "--region", type: "string", subcommand: "pull-s3", envVar: "AWS_REGION", description: "AWS リージョン(env AWS_REGION)" },
225
246
  { name: "--from", type: "string", subcommand: "pull-gcs", description: "取得元 gs://bucket/prefix" },
226
247
  { name: "--out", type: "string", subcommand: "pull-gcs", description: "取得先ローカルディレクトリ" },
248
+ { name: "--gcp-credentials", type: "string", subcommand: "pull-gcs", envVar: "GOOGLE_APPLICATION_CREDENTIALS", description: "無人取り戻し用 SA 鍵 JSON のパス(env GOOGLE_APPLICATION_CREDENTIALS)" },
227
249
  { name: "--from", type: "string", subcommand: "pull-azure", description: "取得元 https://<account>.blob.core.windows.net/<container>/<prefix>" },
228
250
  { name: "--out", type: "string", subcommand: "pull-azure", description: "取得先ローカルディレクトリ" },
229
251
  { name: "--sas", type: "string", subcommand: "pull-azure", secret: true, envVar: "AZURE_STORAGE_SAS_TOKEN",
@@ -350,15 +372,23 @@ function usage() {
350
372
  --history --regist-from --regist-to --update-from --update-to
351
373
  -k/--system-key <n=v> (repeatable)
352
374
  ${PLUGIN} push-s3 <archive-dir> --to s3://bucket/prefix [--cleanup] [--dry-run]
375
+ [--access-key-id <id> --secret-access-key <key> | --profile <name>] [--region <r>]
353
376
  ${PLUGIN} push-gcs <archive-dir> --to gs://bucket/prefix [--cleanup] [--dry-run]
377
+ [--gcp-credentials <service-account-key.json path>]
354
378
  ${PLUGIN} push-azure <archive-dir> --to https://<account>.blob.core.windows.net/<container>/<prefix>
355
379
  [--sas <token> | --connection-string <cs>] [--cleanup] [--dry-run]
356
380
  ${PLUGIN} push-local <archive-dir> --to <folder|network-share path> [--cleanup] [--dry-run]
357
381
  --cleanup Delete the local archive after the upload is verified
358
382
  (gcs requires gcloud; azure requires az CLI. push-local needs no external CLI
359
383
  (Node fs copy) and accepts a plain folder or mounted network-share path.
360
- secrets are read from --sas/--connection-string or AZURE_STORAGE_* env and
361
- passed to az via env, never argv)`);
384
+ Unattended creds are read from the flags above OR env
385
+ (s3: AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_PROFILE/AWS_REGION; gcs:
386
+ GOOGLE_APPLICATION_CREDENTIALS; azure: AZURE_STORAGE_*) and passed to the
387
+ cloud CLI via env, never argv — no interactive login needed.
388
+ Note: AWS keys are a pair (both required); --profile and IAM keys are mutually
389
+ exclusive. When run under \`i-repo\` (dispatch/deliver) the parent env is
390
+ allow-listed, so pass creds via these flags, host env-injection, or
391
+ \`i-repo config set pluginPassEnv "AWS_*,GOOGLE_APPLICATION_CREDENTIALS,AZURE_STORAGE_*"\`)`);
362
392
  }
363
393
 
364
394
  // ── Tiny argv parser (no dependencies) ─────────────────────────────────────
@@ -475,8 +505,13 @@ function normalizeLocalDest(to) {
475
505
  function parsePushArgs(argv, backend) {
476
506
  const label = `push-${backend.store}`;
477
507
  const spec = { "--to": "value", "--prefix": "value", "--dry-run": "flag", "--cleanup": "flag" };
478
- // Azure は認証 secret を受ける(値は argv に出さず env az に渡す。buildPushEnv 参照)。
508
+ // 無人認証フラグ(#92)。secret 値は argv に出さず env でクラウド CLI に渡す(buildPushEnv 参照)。
479
509
  if (backend.store === "azure") { spec["--sas"] = "value"; spec["--connection-string"] = "value"; }
510
+ if (backend.store === "s3") {
511
+ spec["--access-key-id"] = "value"; spec["--secret-access-key"] = "value";
512
+ spec["--profile"] = "value"; spec["--region"] = "value";
513
+ }
514
+ if (backend.store === "gcs") { spec["--gcp-credentials"] = "value"; }
480
515
  const o = parseArgv(argv, spec, label);
481
516
  if (o._.length !== 1) throw new Error(`${label} requires exactly one <archive-dir> argument.`);
482
517
  // local は scheme 付き URL ではなくファイルパス(フォルダ/ネットワーク共有)。scheme 検査を外し、
@@ -499,6 +534,10 @@ function parsePushArgs(argv, backend) {
499
534
  out.sas = o.sas || process.env.AZURE_STORAGE_SAS_TOKEN || "";
500
535
  out.connectionString = o["connection-string"] || process.env.AZURE_STORAGE_CONNECTION_STRING || "";
501
536
  }
537
+ // s3/gcs 無人認証(#92)。フラグ指定時のみ拾う(未指定なら ambient/host 注入の env が
538
+ // buildPushEnv の {...process.env} で素通り=envVar 宣言と整合)。
539
+ if (backend.store === "s3") Object.assign(out, s3CredsFromArgs(o, label));
540
+ if (backend.store === "gcs") out.gcpCredentials = o["gcp-credentials"] || "";
502
541
  return out;
503
542
  }
504
543
 
@@ -666,6 +705,26 @@ function joinUri(prefix, child) {
666
705
  }
667
706
 
668
707
  // secret(SAS / 接続文字列)は argv に載せず az が読む環境変数で渡す(AGENTS.md / 本番化方針)。
708
+ /**
709
+ * s3 無人認証フラグを検証して取り出す(#92)。IAM キーは**対**でのみ意味を持つ
710
+ * (AWS は片方だけだと "Partial credentials" で失敗し、明示キーは profile/ambient より
711
+ * 優先されるため)。キーと --profile の併用も aws では profile が黙殺されるので拒否する
712
+ * (usage は排他表記)。フラグ無指定時は ambient/host 注入の env が素通り。
713
+ */
714
+ function s3CredsFromArgs(o, label) {
715
+ const accessKeyId = o["access-key-id"] || "";
716
+ const secretAccessKey = o["secret-access-key"] || "";
717
+ const profile = o.profile || "";
718
+ const region = o.region || "";
719
+ if (Boolean(accessKeyId) !== Boolean(secretAccessKey)) {
720
+ throw new Error(`${label}: --access-key-id and --secret-access-key must be provided together.`);
721
+ }
722
+ if ((accessKeyId || secretAccessKey) && profile) {
723
+ throw new Error(`${label}: use either IAM keys (--access-key-id/--secret-access-key) or --profile, not both.`);
724
+ }
725
+ return { accessKeyId, secretAccessKey, profile, region };
726
+ }
727
+
669
728
  function buildPushEnv(options) {
670
729
  const env = { ...process.env };
671
730
  if (options.connectionString) env.AZURE_STORAGE_CONNECTION_STRING = options.connectionString;
@@ -674,6 +733,21 @@ function buildPushEnv(options) {
674
733
  // RBAC-only / listKeys 無効アカウントで失敗するため・Codex #76 P2)。s3/gcs は options.azure
675
734
  // 未設定なので素通り。
676
735
  else if (options.azure) env.AZURE_STORAGE_AUTH_MODE = "login";
736
+ // s3 無人認証(#92): フラグ指定時に env を上書き。未指定なら ambient/host 注入が素通り。
737
+ if (options.accessKeyId) env.AWS_ACCESS_KEY_ID = options.accessKeyId;
738
+ if (options.secretAccessKey) env.AWS_SECRET_ACCESS_KEY = options.secretAccessKey;
739
+ if (options.profile) env.AWS_PROFILE = options.profile;
740
+ // AWS_REGION(CLI v2)と AWS_DEFAULT_REGION(v1/botocore 系)の双方を立てる。
741
+ if (options.region) { env.AWS_REGION = options.region; env.AWS_DEFAULT_REGION = options.region; }
742
+ // gcs 無人認証(#92): SA 鍵パスを env で渡す(対話 login 不要)。
743
+ // GOOGLE_APPLICATION_CREDENTIALS は ADC/クライアントライブラリ用で、**gcloud CLI 自体は
744
+ // 読まない**(gcloud は CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE を読む・Codex #93 P1)。
745
+ // 両方を立てて gcloud storage と client-lib の双方をカバーする。フラグが無くても
746
+ // ambient/host 注入の GOOGLE_APPLICATION_CREDENTIALS があれば gcloud 用に複写する。
747
+ if (options.gcpCredentials) env.GOOGLE_APPLICATION_CREDENTIALS = options.gcpCredentials;
748
+ if (env.GOOGLE_APPLICATION_CREDENTIALS && !env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE) {
749
+ env.CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE = env.GOOGLE_APPLICATION_CREDENTIALS;
750
+ }
677
751
  return env;
678
752
  }
679
753
 
@@ -876,7 +950,13 @@ async function pushArchiveBundle(options, backend) {
876
950
  function parsePullArgs(argv, backend) {
877
951
  const label = `pull-${backend.store}`;
878
952
  const spec = { "--from": "value", "--out": "value" };
953
+ // 無人認証(#92・push と対称): pull も対話ログイン無しで取り戻せる。
879
954
  if (backend.store === "azure") { spec["--sas"] = "value"; spec["--connection-string"] = "value"; }
955
+ if (backend.store === "s3") {
956
+ spec["--access-key-id"] = "value"; spec["--secret-access-key"] = "value";
957
+ spec["--profile"] = "value"; spec["--region"] = "value";
958
+ }
959
+ if (backend.store === "gcs") { spec["--gcp-credentials"] = "value"; }
880
960
  const o = parseArgv(argv, spec, label);
881
961
  if (!o.from) throw new Error(`${label} requires --from ${backend.scheme || "<source>"}…`);
882
962
  if (!o.out) throw new Error(`${label} requires --out <local-dir>.`);
@@ -895,6 +975,8 @@ function parsePullArgs(argv, backend) {
895
975
  out.sas = o.sas || process.env.AZURE_STORAGE_SAS_TOKEN || "";
896
976
  out.connectionString = o["connection-string"] || process.env.AZURE_STORAGE_CONNECTION_STRING || "";
897
977
  }
978
+ if (backend.store === "s3") Object.assign(out, s3CredsFromArgs(o, label));
979
+ if (backend.store === "gcs") out.gcpCredentials = o["gcp-credentials"] || "";
898
980
  return out;
899
981
  }
900
982
 
@@ -107,6 +107,11 @@
107
107
  "description": "true かつ id が alreadyDelivered に含まれるとき、spawn せず skipped(delivered:true) として集約する。",
108
108
  "type": "boolean",
109
109
  "default": false
110
+ },
111
+ "secretEnv": {
112
+ "description": "この sink のプロセスへ転送する env 変数名のリスト(§9.9・#94)。値は deliver プロセスの env(host が設定)から取り、この sink だけに注入する(argv 非掲載・他 sink へ漏れない)。fan-out 経由の無人クラウド認証用。プラグインの envVar 宣言に依存しない。",
113
+ "type": "array",
114
+ "items": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }
110
115
  }
111
116
  },
112
117
  "allOf": [
@@ -737,6 +737,16 @@ L3 カタログの単一書き手は GEMBA OS(§4.2)。`deliver` は **catal
737
737
  - bundle sink の `--cleanup`(destructive)はハブを壊すため、`deliver` は**全 sink 成功時に最後に実行**するか、`--archive-keep` を推奨して警告する(黙ってハブを消して他 sink を取りこぼさない)。
738
738
  - 既定は run-to-completion(全 sink を走らせ切り、再送集合を完全にする)。run-level の AbortSignal で最初の hard 失敗時に兄弟を中止する選択肢は持つが既定オフ。
739
739
 
740
+ ### 9.9 per-sink `secretEnv`(無人クラウド認証の host 主導注入・#94)
741
+
742
+ fan-out(`deliver`)経由で sink を起動するとき、dispatch の env allow-list(system+`IREPO_*`+`pluginPassEnv`)が `AWS_*`/`GOOGLE_APPLICATION_CREDENTIALS`/`AZURE_STORAGE_*` 等を scrub するため、無人クラウド認証の資格情報が sink に届かない。これを **host 主導**で埋める。
743
+
744
+ - spec の各 sink に **`secretEnv: [<env 名>, …]`**(その sink へ転送する env 変数の**名前**リスト)。**値は `deliver` プロセスの env**(host=DC が設定)から取り、**その sink のプロセスにだけ**注入する。
745
+ - secret は **env のみ**で渡し **argv には出さない**。**他 sink・他プラグインには漏れない**(allow-list をグローバルに広げない)。spec ファイルに値は持たせない(名前だけ)。
746
+ - **プラグインの `envVar` 宣言には依存しない**=任意の第三者プラグイン(`i-repo-kintone` 等)が宣言なしで動く。プラグイン側 `envVar` は GUI の入力欄ヒント用。
747
+ - env 名は `^[A-Za-z_][A-Za-z0-9_]*$`。deliver プロセスに無い名前は黙って飛ばす(host 未設定=その sink は認証できず失敗するだけ)。
748
+ - 例: `{ "id":"s3-main","plugin":"archive","subcommand":"push-s3","secretEnv":["AWS_ACCESS_KEY_ID","AWS_SECRET_ACCESS_KEY"] }`(archive 0.6.3 の push-s3 が env から IAM キーを読む・#92)。
749
+
740
750
  ---
741
751
 
742
752
  ## 付録A: ウォークスルー(CLI 直叩き経路)