pi-codex-marketplace 0.4.0 → 0.6.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/README.md CHANGED
@@ -37,7 +37,7 @@ Nine subcommands, no arguments = 總覽:
37
37
 
38
38
  | 子命令 | 行為 |
39
39
  |--------|------|
40
- | `add <路徑\|網址>` | 註冊 marketplace(本機資料夾、GitHub 完整網址、`owner/repo` 簡寫皆收),自動偵測 codex/claude 格式並告知 `偵測:<format> marketplace · N plugins`。重複註冊同來源被拒絕並提示下一步。Git 來源以安全線取得(`core.hooksPath=/dev/null`、`GIT_LFS_SKIP_SMUDGE=1`、`GIT_TERMINAL_PROMPT=0`),catalog 解析失敗明示錯誤、不註冊。 |
40
+ | `add <路徑\|網址>` | 註冊 marketplace(本機資料夾、GitHub 完整網址、`owner/repo` 簡寫皆收),自動偵測 codex/claude 格式並告知 `偵測:<format> marketplace · N plugins`。重複註冊同來源被拒絕並提示下一步。Git 來源以安全線取得(`core.hooksPath=/dev/null`、`GIT_LFS_SKIP_SMUDGE=1`、`GIT_TERMINAL_PROMPT=0`),catalog 解析失敗明示錯誤、不註冊。私有 HTTPS repo **開箱即用**:自動偵測本機憑證來源(`gh` 登入/macOS 鑰匙圈/`credential-store`)並逐次核准;也可用 `PI_CODEX_MARKETPLACE_CREDENTIAL_HELPERS` 顯式覆蓋核准清單(逐次生效、永不持久化),或改用 SSH 定位器;用法與範例見下方〈私有 Git repo:Credentialed Acquisition〉。 |
41
41
  | `list [名稱]` | 列出 plugins(編號/所屬 marketplace/狀態:可安裝・已裝啟用・已裝停用・unavailable+原因),可帶 marketplace 名稱過濾。 |
42
42
  | `install <編號\|名稱>` | 裝到**當下最新**並自動啟用+reload。成功話術如 `安裝 "name"(N skills:a, b, c)· 已重新載入生效`;同名衝突列出 `⚠ skill "b" 與既有同名,未投影(名稱衝突)`。**重複安裝=重抓最新覆寫**(重裝=更新,不報錯)。 |
43
43
  | `update` | 對全部已註冊 marketplace 重抓最新:有變化的 plugin 升到最新、無變化各自顯示「無變化」;整體以「已重新載入生效」收尾(有變時)。 |
@@ -53,6 +53,46 @@ Nine subcommands, no arguments = 總覽:
53
53
  - 安裝成功後由指令層主動要求 reload;reload 失敗不影響已記錄狀態,下次 session start 或 `/reload` 仍生效。
54
54
  - `--no-skills` 啟動 Pi 不影響 Bridge 投影。
55
55
 
56
+ ### 私有 Git repo:Credentialed Acquisition(核准式取得)
57
+
58
+ 對私有 HTTPS repo,`add`/`update` **開箱即用**:預設會自動偵測本機已存在的憑證來源並逐次核准(**固定白名單**,不讀本機 gitconfig 的任意 helper;偵測結果只限該次呼叫、永不持久化):
59
+
60
+ | 偵測來源 | 核准的 credential helper |
61
+ |---|---|
62
+ | `gh` CLI 已登入(`gh auth status` 成功) | `!gh auth git-credential` |
63
+ | macOS 原生鑰匙圈 helper 存在 | `osxkeychain` |
64
+ | git `credential-store` 存在 | `store`(明文憑證檔,請知悉風險) |
65
+
66
+ 已註冊或欲手動控制時,以逗號分隔設定環境變數——**顯式設定完全覆蓋自動偵測**(逐次生效、永不持久化,`add` 與 `update` 共用同一核准來源):
67
+
68
+ ```
69
+ PI_CODEX_MARKETPLACE_CREDENTIAL_HELPERS='store, !f() { echo "username=${GITHUB_USER}"; echo "password=${GITHUB_TOKEN}"; }; f'
70
+ /codex-marketplace add https://github.com/acme/private-mkt
71
+ ```
72
+
73
+ Credentialed Acquisition 語意(安全線):
74
+
75
+ - 預設(未設 env)=自動偵測固定白名單(gh/osxkeychain/store),僅限該次呼叫:**不寫入** Bridge State、設定檔或任何持久化位置;偵測到的 helper 以命令列 `credential.helper=` 傳入,gitconfig 的其他 helper(含 URL-scoped)仍被排除。
76
+ - 設定 env 後完全覆蓋:值為 git `credential.helper` 字串,逗號分隔、各項 trim、空項目忽略;未設定且偵測無結果=無任何 helper(行為與 credential-free 完全相同,安全線其餘禁制不變)。
77
+ - 憑證與核准清單**永不**進入指令輸出、Bridge State、Canonical Git Locator(定位器)、Validation Snapshot(快照)或 cache identity(快取身份)——取得流程的 identity 判定與憑證完全無關。
78
+ - 自動偵測或已核准的 helper 仍被遠端拒絕(401)時,錯誤訊息提示檢查登入(`gh auth status`/keychain)或設環境變數核准其他 helper;本機偵測不到任何憑證來源時提示設環境變數或改用 SSH。
79
+ - 設定的 helper 名稱無效時(例如直接寫 `gh`——它不是 git 原生的 credential helper 執行檔),錯誤訊息指出正確寫法:原生 helper 名稱(`osxkeychain`/`store`)或 shell form(`!gh auth git-credential`)。
80
+
81
+ #### SSH 定位器:私有 repo 的替代路徑
82
+
83
+ 私有 repo 可以完全繞過此環境變數,直接用 SSH 定位器註冊(HTTPS 與 SSH 同屬允許的 credential-free 定位器):
84
+
85
+ ```
86
+ /codex-marketplace add git@github.com:acme/private-mkt # scp-like 簡寫(canonical:ssh://git@github.com/acme/private-mkt)
87
+ /codex-marketplace add ssh://git@github.com/acme/private-mkt
88
+ ```
89
+
90
+ 前提(與既有 Acquisition Trust Base 一致):
91
+
92
+ - host key 必須**預先存在** `~/.ssh/known_hosts`——安全線以 `StrictHostKeyChecking=yes` 只信任既有 host key,遇到未知或變更的主機金鑰直接拒絕,不會提示接受;
93
+ - 憑證由 SSH agent 提供,整個取得過程不互動(`BatchMode=yes`)、無任何提示;
94
+ - SSH 定位器本身仍維持 credential-free:不得內嵌密碼(`user:pass@` 拒絕);憑證只能經由 SSH agent 或 Credentialed Acquisition 到達取得流程。
95
+
56
96
  ## Bridge State storage
57
97
 
58
98
  Bridge State 是唯一權威,存於**單一 Global Scope 文件** `{getAgentDir()}/codex-marketplace/state.json`(`~/.pi/agent/codex-marketplace/state.json`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-marketplace",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Bridge Package for Codex and Claude Marketplace compatibility in Pi — 極簡 /codex-marketplace 純文字指令(add/list/install/update/disable/enable/remove/forget)、單一 Global Bridge State、當下最新安裝與即時投影",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -31,6 +31,11 @@ import { findEntryByManifestName, GIT_FAMILY_UNAVAILABLE_REASON, type Catalog, t
31
31
  import type { ValidationFinding } from '../registration/findings.js';
32
32
  import { normalizeGitLocator } from '../registration/git-locator.js';
33
33
  import { acquireGitSource, cleanupAcquisition, type GitExecutor } from '../registration/git-acquisition.js';
34
+ import {
35
+ CREDENTIAL_HELPERS_ENV,
36
+ resolveApprovedHelpers,
37
+ type CredentialHelperDetector,
38
+ } from '../registration/credential-helpers.js';
34
39
  import { gitSourceKey } from '../registration/source-key.js';
35
40
  import { buildGitSnapshot } from '../registration/snapshot.js';
36
41
  import { SourceCache } from '../cache/source-cache.js';
@@ -42,6 +47,8 @@ export interface CommandOptions {
42
47
  cwd?: string;
43
48
  /** Git executor seam for tests — mocks `git` invocations (ls-remote/clone/checkout). */
44
49
  gitExecutor?: GitExecutor;
50
+ /** 自動偵測 seam for tests — mocks gh/keychain/store detection (#117). */
51
+ credentialHelperDetector?: CredentialHelperDetector;
45
52
  }
46
53
 
47
54
  export interface CommandResult {
@@ -598,6 +605,15 @@ export async function runCommand(
598
605
  ): Promise<CommandResult> {
599
606
  const rawArgs = typeof argv === 'string' ? argv.trim().split(/\s+/).filter(Boolean) : [...argv];
600
607
 
608
+ // Credentialed Acquisition (#109,#117):逐次核准的 credential helper allowlist。
609
+ // env 顯式設定 → 完全覆蓋;未設定/空白 → 自動偵測固定白名單(gh/keychain/store),
610
+ // 開箱即用(私有 repo 不再要求先設 env)。解析結果只經既有 AcquisitionTrustOptions
611
+ // 傳給 Git 取得;底層不讀環境變數,未核准(偵測也無結果)時 trust 為 undefined,
612
+ // 行為與 credential-free 完全一致。
613
+ const resolved = resolveApprovedHelpers(process.env[CREDENTIAL_HELPERS_ENV], opts.credentialHelperDetector);
614
+ const acquireTrust: { allowedCredentialHelpers: string[]; helperMode: 'detected' | 'approved' } | undefined =
615
+ resolved.helpers.length > 0 ? { allowedCredentialHelpers: resolved.helpers, helperMode: resolved.mode as 'detected' | 'approved' } : undefined;
616
+
601
617
  // Strip leading command token if passed
602
618
  if (rawArgs.length > 0 && (rawArgs[0] === '/codex-marketplace' || rawArgs[0] === 'codex-marketplace')) {
603
619
  rawArgs.shift();
@@ -671,6 +687,7 @@ export async function runCommand(
671
687
  acquireResult = await acquireGitSource({
672
688
  locator,
673
689
  executor: opts.gitExecutor,
690
+ trust: acquireTrust,
674
691
  });
675
692
  } catch (e) {
676
693
  const msg = e instanceof Error ? e.message : String(e);
@@ -1089,6 +1106,7 @@ export async function runCommand(
1089
1106
  acquireResult = await acquireGitSource({
1090
1107
  locator: locRes.locator!,
1091
1108
  executor: opts.gitExecutor,
1109
+ trust: acquireTrust,
1092
1110
  });
1093
1111
  } catch (e) {
1094
1112
  const msg = e instanceof Error ? e.message : String(e);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Credentialed Acquisition approval (#109) — env-var allowlist parsing.
3
+ * See CONTEXT.md: Credentialed Acquisition; docs/adr/0006-credentialed-acquisition-approval.md.
4
+ *
5
+ * Per-invocation approval of git credential helpers via
6
+ * `PI_CODEX_MARKETPLACE_CREDENTIAL_HELPERS` (comma-separated `credential.helper`
7
+ * strings). Empty/unset means "not approved" — but since #117 the approval may also
8
+ * come from an auto-detected fixed allowlist, so empty/unset falls back to detection.
9
+ * This module only parses/detects; the resulting allowlist travels through
10
+ * AcquisitionTrustOptions and never touches Bridge State, snapshots, or cache identity.
11
+ */
12
+
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ export const CREDENTIAL_HELPERS_ENV = 'PI_CODEX_MARKETPLACE_CREDENTIAL_HELPERS';
18
+
19
+ /**
20
+ * 允許清單來源:#117 開箱即用後,credential helper 可能有兩種來源。
21
+ * - approved:使用者經 `PI_CODEX_MARKETPLACE_CREDENTIAL_HELPERS` 顯式核准(可完全覆蓋偵測)。
22
+ * - detected:預設自動偵測的固定白名單(gh CLI/macOS keychain/git credential-store)。
23
+ * - none:無任何 helper 可用(錯誤訊息給「未核准」指引變體)。
24
+ */
25
+ export type CredentialHelperMode = 'none' | 'detected' | 'approved';
26
+
27
+ /**
28
+ * 自動偵測介面——可注入(測試)或使用預設真機偵測。偵測目標是**固定白名單**,
29
+ * 不是讀本機 gitconfig:gitconfig 的任意 helper(含 URL-scoped)仍被命令列
30
+ * `credential.helper=` 清空排除,僅白名單上的 helper 參與(#117,與 ADR 0006
31
+ * 被拒的「隱式信任本機 git config」不同)。
32
+ */
33
+ export interface CredentialHelperDetector {
34
+ /** gh CLI 已登入(`gh auth status` exit 0) */
35
+ ghLoggedIn(): boolean;
36
+ /** git 原生 credential helper 執行檔存在(`git --exec-path` 或 PATH) */
37
+ hasGitHelper(name: string): boolean;
38
+ }
39
+
40
+ const defaultDetector: CredentialHelperDetector = {
41
+ ghLoggedIn() {
42
+ try {
43
+ const res = spawnSync('gh', ['auth', 'status'], { stdio: 'ignore', timeout: 5000 });
44
+ return res.status === 0;
45
+ } catch {
46
+ return false;
47
+ }
48
+ },
49
+ hasGitHelper(name) {
50
+ try {
51
+ const execPath = spawnSync('git', ['--exec-path'], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
52
+ if (execPath.status === 0) {
53
+ const dir = String(execPath.stdout ?? '').trim();
54
+ if (dir && existsSync(join(dir, `git-${name}`))) return true;
55
+ }
56
+ } catch {
57
+ // fall through to PATH check
58
+ }
59
+ try {
60
+ const which = spawnSync('sh', ['-c', `command -v git-${name}`], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
61
+ return which.status === 0 && String(which.stdout ?? '').trim().length > 0;
62
+ } catch {
63
+ return false;
64
+ }
65
+ },
66
+ };
67
+
68
+ /**
69
+ * Parse the approval env var into a helper allowlist.
70
+ * Comma-separated, each entry trimmed, empty entries ignored.
71
+ * Empty/unset → no explicit approval ([]).
72
+ */
73
+ export function parseCredentialHelpers(raw: string | undefined): string[] {
74
+ if (!raw) return [];
75
+ return raw
76
+ .split(',')
77
+ .map((entry) => entry.trim())
78
+ .filter((entry) => entry.length > 0);
79
+ }
80
+
81
+ export interface ResolvedCredentialAllowlist {
82
+ helpers: string[];
83
+ mode: CredentialHelperMode;
84
+ }
85
+
86
+ /**
87
+ * 解析最終 allowlist(#117 開箱即用):
88
+ * - env 有非空內容 → 顯式核准,完全覆蓋自動偵測(可預測性不變)。
89
+ * - env 未設定/空白 → 自動偵測固定白名單。
90
+ */
91
+ export function resolveApprovedHelpers(
92
+ raw: string | undefined,
93
+ detector: CredentialHelperDetector = defaultDetector,
94
+ ): ResolvedCredentialAllowlist {
95
+ const parsed = parseCredentialHelpers(raw);
96
+ if (parsed.length > 0) return { helpers: parsed, mode: 'approved' };
97
+ const detected: string[] = [];
98
+ if (detector.ghLoggedIn()) detected.push('!gh auth git-credential');
99
+ if (detector.hasGitHelper('credential-osxkeychain')) detected.push('osxkeychain');
100
+ if (detector.hasGitHelper('credential-store')) detected.push('store');
101
+ return { helpers: detected, mode: detected.length > 0 ? 'detected' : 'none' };
102
+ }
@@ -52,6 +52,8 @@ export const RULE = {
52
52
  GIT_TRUST_HOST_KEY: 'GIT-31',
53
53
  GIT_TRUST_REDIRECT: 'GIT-32',
54
54
  GIT_TRUST_CREDENTIAL_HELPER: 'GIT-33',
55
+ GIT_TRUST_AUTH_REQUIRED: 'GIT-34',
56
+ GIT_TRUST_CREDENTIAL_HELPER_INVALID: 'GIT-35',
55
57
  } as const;
56
58
 
57
59
  export const CODE = {
@@ -75,6 +77,9 @@ export const CODE = {
75
77
  GIT_TRUST_HOST_KEY_CHANGED: 'GIT_TRUST_HOST_KEY_CHANGED',
76
78
  GIT_TRUST_REDIRECT: 'GIT_TRUST_REDIRECT',
77
79
  GIT_TRUST_CREDENTIAL_HELPER: 'GIT_TRUST_CREDENTIAL_HELPER',
80
+ GIT_TRUST_AUTH_REQUIRED: 'GIT_TRUST_AUTH_REQUIRED',
81
+ GIT_TRUST_CREDENTIAL_HELPER_INVALID: 'GIT_TRUST_CREDENTIAL_HELPER_INVALID',
82
+ GIT_REPO_NOT_FOUND: 'GIT_REPO_NOT_FOUND',
78
83
  } as const;
79
84
 
80
85
  const CLASS_RANK: Record<FindingClass, number> = { blocking: 0, warning: 1, notice: 2 };
@@ -15,6 +15,7 @@ import { join } from 'node:path';
15
15
 
16
16
  import { CODE, RULE, blocking, type ValidationFinding } from './findings.js';
17
17
  import type { CanonicalGitLocator } from './git-locator.js';
18
+ import { CREDENTIAL_HELPERS_ENV, type CredentialHelperMode } from './credential-helpers.js';
18
19
 
19
20
  export interface GitExecutor {
20
21
  (args: string[], opts?: { cwd?: string; env?: Record<string, string> }): Promise<{
@@ -46,6 +47,8 @@ export function defaultGitExecutor(): GitExecutor {
46
47
  export interface AcquisitionTrustOptions {
47
48
  knownHostsFile?: string;
48
49
  allowedCredentialHelpers?: string[];
50
+ /** 允許清單來源(#117):approved=env 顯式核准;detected=自動偵測白名單;缺省視為 approved(相容既有 caller)。 */
51
+ helperMode?: CredentialHelperMode;
49
52
  gitPath?: string;
50
53
  sshCommand?: string;
51
54
  allowRedirects?: boolean;
@@ -126,50 +129,141 @@ function isFullHex(s: string): boolean {
126
129
  return /^[0-9a-f]{40}$/.test(s) || /^[0-9a-f]{64}$/.test(s);
127
130
  }
128
131
 
132
+ /**
133
+ * 共用失敗分類(#110):ls-remote 與 clone 兩條取得路徑採用同一分類與訊息。
134
+ * - auth:伺服器 401(authentication failed)→ GIT-34,訊息依 helper 模式分三變體(none/detected/approved)。
135
+ * - invalid-helper:核准的 helper 名稱無效(git 找不到對應執行檔,如 `gh`)→ GIT-35,
136
+ * 訊息指出正確寫法(原生 helper 名稱或 `!命令` shell form)。放在 auth 之前:stderr 常同時含兩者。
137
+ * - not-found:「repository not found」類字串 → 標明 repo 不存在(保留非 GitHub 情境;
138
+ * GitHub smart-HTTP 對不存在 repo 實測回 401,落入 auth 分支)。
139
+ * - helper:credential source 拒絕(原字串匹配的 helper 拒絕情境)→ GIT-33 保留。
140
+ */
141
+ type FailureKind =
142
+ | { kind: 'host-key'; isChanged: boolean }
143
+ | { kind: 'redirect' }
144
+ | { kind: 'not-found' }
145
+ | { kind: 'invalid-helper'; name: string }
146
+ | { kind: 'auth'; mode: CredentialHelperMode }
147
+ | { kind: 'helper'; mode: CredentialHelperMode };
148
+
149
+ const INVALID_HELPER_RE = /git: 'credential-([^']+)' is not a git command/i;
150
+
151
+ /**
152
+ * 從 allowlist 推導模式:未提供時視為 approved(既有 caller 語意);
153
+ * 空 allowlist → none。
154
+ */
155
+ function modeOf(trust: AcquisitionTrustOptions | undefined): CredentialHelperMode {
156
+ if (trust?.helperMode) return trust.helperMode;
157
+ return (trust?.allowedCredentialHelpers?.length ?? 0) > 0 ? 'approved' : 'none';
158
+ }
159
+
160
+ function classifyFailure(stderr: string, mode: CredentialHelperMode): FailureKind | null {
161
+ const lower = stderr.toLowerCase();
162
+ if (lower.includes('host key verification failed') || lower.includes('unknown host key') || lower.includes('offending')) {
163
+ return { kind: 'host-key', isChanged: lower.includes('changed') || lower.includes('offending') || lower.includes('key changed') };
164
+ }
165
+ if (lower.includes('redirect') || lower.includes('moved') || lower.includes('followredirects')) {
166
+ return { kind: 'redirect' };
167
+ }
168
+ if (lower.includes('repository not found') || lower.includes('repo not found') || lower.includes('does not appear to be a git repository') || lower.includes("' not found")) {
169
+ return { kind: 'not-found' };
170
+ }
171
+ const invalidMatch = stderr.match(INVALID_HELPER_RE);
172
+ if (invalidMatch && (lower.includes('credentials') || lower.includes('credential-'))) {
173
+ return { kind: 'invalid-helper', name: invalidMatch[1] };
174
+ }
175
+ if (lower.includes('authentication failed')) {
176
+ return { kind: 'auth', mode };
177
+ }
178
+ if (
179
+ lower.includes('could not read username') ||
180
+ lower.includes('could not read password') ||
181
+ lower.includes('terminal prompts disabled') ||
182
+ lower.includes('credential')
183
+ ) {
184
+ return { kind: 'helper', mode };
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function failureFinding(kind: FailureKind, locator: CanonicalGitLocator, stderr: string): ValidationFinding {
190
+ switch (kind.kind) {
191
+ case 'host-key':
192
+ return trustFinding(
193
+ kind.isChanged ? CODE.GIT_TRUST_HOST_KEY_CHANGED : CODE.GIT_TRUST_HOST_KEY_UNKNOWN,
194
+ RULE.GIT_TRUST_HOST_KEY,
195
+ `Acquisition Trust Base violation: SSH host key ${kind.isChanged ? 'changed' : 'unknown'} for ${locator.host} — ${stderr.trim()} (only pre-established known-host keys are trusted)`,
196
+ );
197
+ case 'redirect':
198
+ return trustFinding(
199
+ CODE.GIT_TRUST_REDIRECT,
200
+ RULE.GIT_TRUST_REDIRECT,
201
+ `Acquisition Trust Base violation: redirect that would change canonical locator (followRedirects disabled) — ${stderr.trim()}`,
202
+ );
203
+ case 'not-found':
204
+ return trustFinding(
205
+ CODE.GIT_REPO_NOT_FOUND,
206
+ RULE.GIT_TRUST_AUTH_REQUIRED,
207
+ `Acquisition Trust Base violation: repository not found — '${locator.canonicalUrl}' does not exist (check the URL or owner/repo name) — ${stderr.trim()}`,
208
+ );
209
+ case 'invalid-helper': {
210
+ // GIT-35:核准的 helper 名稱無效(git 找不到 `git-credential-<name>` 執行檔)。
211
+ // 典型:使用者直覺設 `PI_CODEX_MARKETPLACE_CREDENTIAL_HELPERS=gh`,git 抱怨
212
+ // `credential-gh` is not a git command;正確寫法是 `!gh auth git-credential`。
213
+ return trustFinding(
214
+ CODE.GIT_TRUST_CREDENTIAL_HELPER_INVALID,
215
+ RULE.GIT_TRUST_CREDENTIAL_HELPER_INVALID,
216
+ `Acquisition Trust Base violation: '${kind.name}' is not a valid git credential helper — use a native helper name (osxkeychain / store) or a shell form like '!gh auth git-credential' — ${stderr.trim()}`,
217
+ );
218
+ }
219
+ case 'auth': {
220
+ // 伺服器 401:訊息依 helper 模式分三變體(#117):
221
+ // - none:本機也偵測不到任何憑證來源 → 指引設 env 或改 SSH。
222
+ // - detected:自動偵測白名單被伺服器拒絕 → 指引檢查登入,或手動核准其他 helper。
223
+ // - approved:顯式核准仍 401 → 指引檢查登入。
224
+ const why =
225
+ kind.mode === 'none'
226
+ ? `repository requires authentication (private or nonexistent); no credential source was detected on this machine (gh CLI / macOS keychain / git credential-store) and this acquisition is credential-free — approve a credential helper via ${CREDENTIAL_HELPERS_ENV}, or switch to an SSH locator`
227
+ : kind.mode === 'detected'
228
+ ? `repository requires authentication; the credential sources auto-detected on this machine (gh / macOS keychain / credential-store) were rejected by the server — check your login with 'gh auth status' or your keychain, or set ${CREDENTIAL_HELPERS_ENV} to approve a different helper`
229
+ : `approved credential helper did not provide valid credentials — check your login with 'gh auth status' or your keychain`;
230
+ return trustFinding(
231
+ CODE.GIT_TRUST_AUTH_REQUIRED,
232
+ RULE.GIT_TRUST_AUTH_REQUIRED,
233
+ `Acquisition Trust Base violation: ${why} — ${stderr.trim()}`,
234
+ );
235
+ }
236
+ case 'helper': {
237
+ // GIT-33 保留:credential source 拒絕(could not read Username/Password 等原字串匹配);
238
+ // 訊息依模式分變體:none 維持原「not approved」措辭。
239
+ const why =
240
+ kind.mode === 'none'
241
+ ? `credential helper/agent not approved — set ${CREDENTIAL_HELPERS_ENV} to approve one, or use SSH`
242
+ : `approved or auto-detected credential helper failed to supply credentials — check your login with 'gh auth status' or your keychain`;
243
+ return trustFinding(
244
+ CODE.GIT_TRUST_CREDENTIAL_HELPER,
245
+ RULE.GIT_TRUST_CREDENTIAL_HELPER,
246
+ `Acquisition Trust Base: ${why} — ${stderr.trim()}`,
247
+ );
248
+ }
249
+ }
250
+ }
251
+
129
252
  async function resolveHead(
130
253
  locator: CanonicalGitLocator,
131
254
  executor: GitExecutor,
132
255
  env: Record<string, string>,
133
256
  configArgs: string[],
257
+ mode: CredentialHelperMode,
134
258
  ): Promise<{ ok: true; sha: string } | { ok: false; findings: ValidationFinding[]; stderr?: string }> {
135
259
  const lsArgs = [...configArgs, 'ls-remote', locator.canonicalUrl, 'HEAD'];
136
260
  const res = await executor(lsArgs, { env });
137
261
  if (res.exitCode !== 0) {
138
- const stderr = (res.stderr || '').toLowerCase();
139
- if (stderr.includes('host key verification failed') || stderr.includes('unknown host key') || stderr.includes('offending')) {
140
- const isChanged = stderr.includes('changed') || stderr.includes('offending') || stderr.includes('key changed');
141
- const code = isChanged ? CODE.GIT_TRUST_HOST_KEY_CHANGED : CODE.GIT_TRUST_HOST_KEY_UNKNOWN;
262
+ const kind = classifyFailure(res.stderr || '', mode);
263
+ if (kind) {
142
264
  return {
143
265
  ok: false,
144
- findings: [
145
- trustFinding(
146
- code,
147
- RULE.GIT_TRUST_HOST_KEY,
148
- `Acquisition Trust Base violation: SSH host key ${isChanged ? 'changed' : 'unknown'} for ${locator.host} — ${res.stderr.trim()} (only pre-established known-host keys are trusted)`,
149
- ),
150
- ],
151
- stderr: res.stderr,
152
- };
153
- }
154
- if (stderr.includes('redirect') || stderr.includes('moved') || stderr.includes('followredirects')) {
155
- return {
156
- ok: false,
157
- findings: [
158
- trustFinding(CODE.GIT_TRUST_REDIRECT, RULE.GIT_TRUST_REDIRECT, `Acquisition Trust Base violation: redirect that would change canonical locator (followRedirects disabled) — ${res.stderr.trim()}`),
159
- ],
160
- stderr: res.stderr,
161
- };
162
- }
163
- if (stderr.includes('could not read username') || stderr.includes('authentication failed') || stderr.includes('credential')) {
164
- return {
165
- ok: false,
166
- findings: [
167
- trustFinding(
168
- CODE.GIT_ACQUISITION_FAILED,
169
- RULE.GIT_TRUST_CREDENTIAL_HELPER,
170
- `Acquisition Trust Base: credential helper/agent not approved — ${res.stderr.trim()}`,
171
- ),
172
- ],
266
+ findings: [failureFinding(kind, locator, res.stderr)],
173
267
  stderr: res.stderr,
174
268
  };
175
269
  }
@@ -211,7 +305,8 @@ export async function resolveGitRevision(
211
305
  ): Promise<{ ok: true; sha: string } | { ok: false; findings: ValidationFinding[]; stderr?: string }> {
212
306
  const env = hardenedEnv(opts.trust, locator);
213
307
  const configArgs = hardenedConfigArgs(opts.trust);
214
- return resolveHead(locator, opts.executor ?? defaultGitExecutor(), env, configArgs);
308
+ const mode = modeOf(opts.trust);
309
+ return resolveHead(locator, opts.executor ?? defaultGitExecutor(), env, configArgs, mode);
215
310
  }
216
311
 
217
312
  /**
@@ -223,8 +318,9 @@ export async function acquireGitSource(opts: AcquireOptions): Promise<AcquireRes
223
318
  const trust = opts.trust;
224
319
  const env = hardenedEnv(trust, locator);
225
320
  const configArgs = hardenedConfigArgs(trust);
321
+ const mode = modeOf(trust);
226
322
 
227
- const resolved = await resolveHead(locator, executor, env, configArgs);
323
+ const resolved = await resolveHead(locator, executor, env, configArgs, mode);
228
324
  if (!resolved.ok) {
229
325
  return { ok: false, findings: (resolved as { findings: ValidationFinding[] }).findings, stderr: (resolved as { stderr?: string }).stderr };
230
326
  }
@@ -243,39 +339,12 @@ export async function acquireGitSource(opts: AcquireOptions): Promise<AcquireRes
243
339
  const cloneRes = await executor(cloneArgs, { env });
244
340
  if (cloneRes.exitCode !== 0) {
245
341
  const stderr = cloneRes.stderr || '';
246
- const lower = stderr.toLowerCase();
247
- if (lower.includes('host key verification failed') || lower.includes('unknown host key') || lower.includes('offending')) {
248
- const isChanged = lower.includes('changed') || lower.includes('offending');
249
- const code = isChanged ? CODE.GIT_TRUST_HOST_KEY_CHANGED : CODE.GIT_TRUST_HOST_KEY_UNKNOWN;
250
- if (createdTemp) try { rmSync(dest, { recursive: true, force: true }); } catch {}
251
- return {
252
- ok: false,
253
- findings: [
254
- trustFinding(
255
- code,
256
- RULE.GIT_TRUST_HOST_KEY,
257
- `Acquisition Trust Base violation: SSH host key ${isChanged ? 'changed' : 'unknown'} — ${stderr.trim()}`,
258
- ),
259
- ],
260
- stderr,
261
- };
262
- }
263
- if (lower.includes('redirect') || lower.includes('moved permanently') || lower.includes('followredirects')) {
264
- if (createdTemp) try { rmSync(dest, { recursive: true, force: true }); } catch {}
265
- return {
266
- ok: false,
267
- findings: [
268
- trustFinding(CODE.GIT_TRUST_REDIRECT, RULE.GIT_TRUST_REDIRECT, `Acquisition Trust Base violation: redirect changing canonical locator — ${stderr.trim()}`),
269
- ],
270
- stderr,
271
- };
272
- }
342
+ const kind = classifyFailure(stderr, mode);
343
+ const finding = kind
344
+ ? failureFinding(kind, locator, stderr)
345
+ : acquireFinding(`git clone failed: ${stderr.trim() || `exit ${cloneRes.exitCode}`}`);
273
346
  if (createdTemp) try { rmSync(dest, { recursive: true, force: true }); } catch {}
274
- return {
275
- ok: false,
276
- findings: [acquireFinding(`git clone failed: ${stderr.trim() || `exit ${cloneRes.exitCode}`}`)],
277
- stderr,
278
- };
347
+ return { ok: false, findings: [finding], stderr };
279
348
  }
280
349
 
281
350
  if (trust?.allowRedirects !== true) {