@mmerterden/multi-agent-pipeline 13.2.0 → 13.4.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/CHANGELOG.md +125 -0
- package/install/_plugin-skills.mjs +246 -0
- package/install/codex.mjs +46 -6
- package/install/copilot.mjs +28 -7
- package/install/templates/codex-instructions.md +23 -0
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +50 -50
- package/pipeline/commands/multi-agent/setup/SKILL.md +2 -1
- package/pipeline/lib/credential-inventory.sh +351 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +32 -1
- package/pipeline/multi-agent-refs/features/external-context-injection.md +43 -2
- package/pipeline/multi-agent-refs/keychain.md +63 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +51 -18
- package/pipeline/multi-agent-refs/rules.md +3 -3
- package/pipeline/scripts/build-stack-plugins.mjs +50 -4
- package/pipeline/scripts/phase0-exit-gate.mjs +52 -0
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +19 -0
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +50 -50
- package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +37 -37
- package/pipeline/skills/shared/external/ios-coding-standard/references/lint-local.sh +5 -5
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +108 -108
- package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +25 -25
|
@@ -5,17 +5,17 @@ argument-hint: "[module name or path]"
|
|
|
5
5
|
allowed-tools: Skill, Bash, Read, Edit, Write, AskUserQuestion
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
# multi-agent ios-coding-standard
|
|
8
|
+
# multi-agent ios-coding-standard - Module audit → plan → dev handoff
|
|
9
9
|
|
|
10
|
-
**Input**: $ARGUMENTS
|
|
10
|
+
**Input**: $ARGUMENTS - optionally a module name or path. When absent, Phase 1 discovers and asks.
|
|
11
11
|
|
|
12
12
|
This routine is the **procedure**. The rules live in the `ios-coding-standard` skill, whose registry is
|
|
13
13
|
`references/rules.yml` and whose teaching doc is `references/STANDARD.md`. Load that
|
|
14
14
|
skill first; it is installed on every host and carried by the iOS stack plugin, so
|
|
15
|
-
there is one registry rather than a copy per CLI. Never restate a rule here
|
|
15
|
+
there is one registry rather than a copy per CLI. Never restate a rule here - cite its ID. A rule that is not in the
|
|
16
16
|
registry is not a rule; if the audit needs one, propose it as `status: proposed` and say so.
|
|
17
17
|
|
|
18
|
-
Read-only + planning. This routine never edits source
|
|
18
|
+
Read-only + planning. This routine never edits source - the dev pipeline does.
|
|
19
19
|
|
|
20
20
|
**Goal.** A developer new to the repo can open any file in the module and understand it without a
|
|
21
21
|
guided tour, and nothing sensitive leaks on the way. Rank every finding by "does fixing this
|
|
@@ -26,26 +26,26 @@ dev task) stay English.
|
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
29
|
-
## Phase 1
|
|
29
|
+
## Phase 1 - Discover and pick the module
|
|
30
30
|
|
|
31
31
|
1. Repo root via `git rev-parse --show-toplevel`.
|
|
32
|
-
2. **Discover module roots**
|
|
32
|
+
2. **Discover module roots** - any directory with a `Package.swift`, a `Sources/` subtree or an
|
|
33
33
|
`.xcodeproj`. Sweep the container dirs that exist (`Domains/`, `Packages/`, `Modules/`,
|
|
34
34
|
`Features/`, `Core/`, `Common/`, top-level module dirs) one level deep, then two if empty.
|
|
35
35
|
3. Record per module: name · path · Swift files · lines · governance docs present · SPM targets.
|
|
36
|
-
Keep this **module registry** for the whole run
|
|
36
|
+
Keep this **module registry** for the whole run - `MOD-*` greps every import against it.
|
|
37
37
|
Classify each module's role, because the role decides which dependency edges are legal:
|
|
38
38
|
**feature** · **core/common** · **seam** (cross-module contracts/bridges/navigation) ·
|
|
39
39
|
**composition root** (legitimately knows every module).
|
|
40
40
|
4. If `$ARGUMENTS` resolves to a module, skip the prompt. Otherwise `AskUserQuestion`
|
|
41
41
|
(single-select, `outputLanguage`), documented modules first, each option showing file/line
|
|
42
42
|
counts and `docs: yes/no`. If the list exceeds the question limit, group the smallest into an
|
|
43
|
-
"other" option and ask again
|
|
43
|
+
"other" option and ask again - never truncate silently.
|
|
44
44
|
5. Scope = the whole module's sources across all its targets, unless the user narrows it.
|
|
45
45
|
|
|
46
|
-
## Phase 2
|
|
46
|
+
## Phase 2 - Resolve what applies to THIS module
|
|
47
47
|
|
|
48
|
-
### 2a. The registry is the standard
|
|
48
|
+
### 2a. The registry is the standard - in-module prose docs are NOT consulted
|
|
49
49
|
|
|
50
50
|
The skill's `references/rules.yml` (+ a project `modules/<Module>.yml` overlay) is the single source of truth. **Do not read, cite, or
|
|
51
51
|
derive rules from a module's own `*-CLAUDE.md`, `docs/` set or any other in-repo prose.** They are
|
|
@@ -64,7 +64,7 @@ the specs the generator reads, and the module registry from Phase 1.
|
|
|
64
64
|
be `blocking`.
|
|
65
65
|
*Why prohibitions need to be written down:* inference reads dominant patterns, and a rule
|
|
66
66
|
obeyed everywhere has no counter-example to infer from. A ban at 100% compliance is invisible
|
|
67
|
-
to a scan
|
|
67
|
+
to a scan - it survives only if the overlay states it.
|
|
68
68
|
2. **Repo-level rules** (root `CLAUDE.md`, contributing docs) apply to every module.
|
|
69
69
|
3. **The module's own dominant pattern is its de-facto convention.** Count variants per dimension;
|
|
70
70
|
the minority instances are the finding. Internal consistency beats conformity to a sibling.
|
|
@@ -74,14 +74,14 @@ Findings from step 3 are tagged `inferred` and capped at **suggestion**. Only re
|
|
|
74
74
|
overlay-backed rules may be `blocking`.
|
|
75
75
|
|
|
76
76
|
**When the overlay is missing**, generate a draft from code evidence, print it for confirmation,
|
|
77
|
-
and mark every slot it could not derive
|
|
77
|
+
and mark every slot it could not derive - a frozen UI target, a name lock, a carve-out and an
|
|
78
78
|
operational constraint all look like ordinary code from the outside. Do not guess them; list them
|
|
79
79
|
as Open Questions.
|
|
80
80
|
|
|
81
81
|
### 2c. Vocabulary
|
|
82
82
|
|
|
83
83
|
Bind each slot to a real symbol by grepping the module, then the reference module. **An unbound
|
|
84
|
-
slot disables its rules**
|
|
84
|
+
slot disables its rules** - never invent a name, never import a sibling's.
|
|
85
85
|
|
|
86
86
|
`HandlerName` · `EventParam` · `CoordinatorType` · `ResultType` / `ErrorType` / `ErrorFactory` ·
|
|
87
87
|
`DIResolver` / `DIConfigurator` · `RequestModelSuffix` / `ResponseModelSuffix` / `TransportSuffix` ·
|
|
@@ -94,14 +94,14 @@ slot disables its rules** — never invent a name, never import a sibling's.
|
|
|
94
94
|
|
|
95
95
|
Print the resolved table before scanning so a wrong binding is caught early.
|
|
96
96
|
|
|
97
|
-
### 2d. Sensitive-data inventory
|
|
97
|
+
### 2d. Sensitive-data inventory - required before any SEC rule runs
|
|
98
98
|
|
|
99
99
|
The `SEC-*` rules are written against the **data classes** in `references/rules.yml →
|
|
100
100
|
sensitive_data_classes`, never against one module's field names. Resolve the module's concrete
|
|
101
101
|
instances of each class: grep entities, request/response models, storage calls and analytics
|
|
102
102
|
events for the values the module actually handles, and map each to its class.
|
|
103
103
|
|
|
104
|
-
Produce the inventory as a table
|
|
104
|
+
Produce the inventory as a table - **class · concrete symbols · where it enters · required
|
|
105
105
|
lifetime (transient / survives-restart / survives-reinstall) · where it is actually stored ·
|
|
106
106
|
where it is logged · where it leaves (network, analytics, pasteboard, another module)**.
|
|
107
107
|
|
|
@@ -117,13 +117,13 @@ whose required lifetime nobody can state, is an Open Question, not a silent pass
|
|
|
117
117
|
|
|
118
118
|
This table is a deliverable in its own right, independent of whether any violation is found.
|
|
119
119
|
|
|
120
|
-
## Phase 3
|
|
120
|
+
## Phase 3 - Scan
|
|
121
121
|
|
|
122
122
|
Apply the rules in `references/rules.yml` that survived Phase 2 binding, plus the module's own `validation`
|
|
123
123
|
gate. Tag every finding: **rule ID · severity · source (`registry` / `doc` / `inferred`) ·
|
|
124
124
|
onboarding impact (high/medium/low)**.
|
|
125
125
|
|
|
126
|
-
### Coverage gate
|
|
126
|
+
### Coverage gate - on disk, not in memory
|
|
127
127
|
|
|
128
128
|
Before scanning, write `.<module>-audit-coverage.tsv` to the scratch dir: one row per target
|
|
129
129
|
(every screen under `ScreenRoot`, every shared component, every entity, every file in every
|
|
@@ -131,25 +131,25 @@ target) with columns `path · status · rules-applied`. Mark rows as you go.
|
|
|
131
131
|
|
|
132
132
|
- Each target is **audited** or **explicitly skipped with a reason** (generated, fixture,
|
|
133
133
|
vendored). No third state.
|
|
134
|
-
- **The run is incomplete while any row is unmarked**
|
|
134
|
+
- **The run is incomplete while any row is unmarked** - and the file, not a claim, is the proof.
|
|
135
135
|
Chunk the module if it is too large for one pass; the checklist survives a context reset.
|
|
136
136
|
- No sampling. A partial audit reported as complete certifies the unscanned screens as clean.
|
|
137
137
|
|
|
138
138
|
### Grep hygiene
|
|
139
139
|
|
|
140
140
|
Registry `mechanism` patterns are line-based and match doc comments. Filter
|
|
141
|
-
`^[[:space:]]*//`
|
|
142
|
-
through
|
|
141
|
+
`^[[:space:]]*//` - BSD grep does not understand `\s`, so a `\s*//` filter silently lets `/// ...`
|
|
142
|
+
through - then open each surviving hit and confirm it is real code. Report pre-filter and
|
|
143
143
|
post-verification counts separately; a table padded with comment matches destroys trust.
|
|
144
144
|
|
|
145
145
|
### Judgement rules need evidence, not assertion
|
|
146
146
|
|
|
147
147
|
For any rule marked `enforcement: judgement`, a finding must carry the measurement its `check`
|
|
148
|
-
names
|
|
148
|
+
names - a reference count, a call-site count, a consumer count, a file list. **No count, no
|
|
149
149
|
finding.** This is what keeps a subjective rule from becoming an opinion.
|
|
150
150
|
|
|
151
151
|
Every judgement rule has a worked ✗/✓ pair in `EXAMPLES.md`, keyed by ID. Cite it in the finding's
|
|
152
|
-
proposed fix rather than re-describing the shape
|
|
152
|
+
proposed fix rather than re-describing the shape - the developer applying the fix should be
|
|
153
153
|
reading the same picture the audit used.
|
|
154
154
|
|
|
155
155
|
### Module-specific overrides
|
|
@@ -158,79 +158,79 @@ The module's own docs win over the registry, including where they contradict a s
|
|
|
158
158
|
Never apply one module's dialect to another. List every override honoured, so the reader sees why
|
|
159
159
|
a registry rule was not raised.
|
|
160
160
|
|
|
161
|
-
## Phase 4
|
|
161
|
+
## Phase 4 - Produce the plan
|
|
162
162
|
|
|
163
163
|
No findings → report compliant and stop. Otherwise write
|
|
164
164
|
`<ModulePath>/ios-coding-standard-plan-<module>.md` (or the repo's scratch/docs dir if the module
|
|
165
165
|
tree must stay clean), containing, in order:
|
|
166
166
|
|
|
167
|
-
1. **Coverage ledger**
|
|
167
|
+
1. **Coverage ledger** - total targets · audited · skipped with reasons. Not 100% accounted for
|
|
168
168
|
means the plan is not deliverable.
|
|
169
|
-
2. **Summary**
|
|
170
|
-
3. **Sensitive-data inventory** (Phase 2d)
|
|
171
|
-
4. **Screen × role matrix** `[STRUCT-02]`
|
|
169
|
+
2. **Summary** - counts by severity and source, resolved vocabulary, branch to create.
|
|
170
|
+
3. **Sensitive-data inventory** (Phase 2d) - first, because it is the highest-risk content.
|
|
171
|
+
4. **Screen × role matrix** `[STRUCT-02]` - screens as rows, manifest roles as columns, cells
|
|
172
172
|
`✓ / missing / misplaced`. The structural map a newcomer reads first.
|
|
173
|
-
5. **Type placement table** `[STRUCT-05]`
|
|
173
|
+
5. **Type placement table** `[STRUCT-05]` - type · location · consumer count · correct tier ·
|
|
174
174
|
move required. Paired with the nested-type list `[STRUCT-01]`: owner · nested type · kind ·
|
|
175
175
|
reference count · verdict.
|
|
176
|
-
6. **Visibility report** `[VIS-01, VIS-02]`
|
|
176
|
+
6. **Visibility report** `[VIS-01, VIS-02]` - over-exposed declarations per file, non-final
|
|
177
177
|
classes with no subclass.
|
|
178
|
-
7. **Boundary report** `[MOD-*]`
|
|
178
|
+
7. **Boundary report** `[MOD-*]` - import graph (allowed vs forbidden, with coupling depth) ·
|
|
179
179
|
manifest graph (declared vs legal for the role, plus dead edges) · **removability delta**
|
|
180
|
-
("removing X touches N files:
|
|
181
|
-
8. **Testability seam report** `[TEST-*]`
|
|
180
|
+
("removing X touches N files: ...") · inbound test (tests compile with no sibling present).
|
|
181
|
+
8. **Testability seam report** `[TEST-*]` - types reaching for the environment, logic requiring a
|
|
182
182
|
view to execute, doubles whose signatures have drifted.
|
|
183
|
-
9. **Change-cost probe** `[FLEX-*]`
|
|
183
|
+
9. **Change-cost probe** `[FLEX-*]` - pick two plausible upcoming changes (a new variant of an
|
|
184
184
|
existing screen; a new field on a shared entity) and state what each costs in files today.
|
|
185
|
-
10. **Findings table**
|
|
185
|
+
10. **Findings table** - rule ID · `file:line` · current state · proposed fix · severity · source ·
|
|
186
186
|
onboarding impact.
|
|
187
|
-
11. **Per-file work**
|
|
187
|
+
11. **Per-file work** - grouped per file, blocking → important → suggestion; within a severity,
|
|
188
188
|
highest onboarding impact first.
|
|
189
|
-
12. **Behaviour-preservation guard**
|
|
189
|
+
12. **Behaviour-preservation guard** - splitting files, adding MARKs, extracting extensions and
|
|
190
190
|
moving types must not change behaviour. Anything requiring a behaviour change (a missing
|
|
191
191
|
analytics event, a wrong result type, a timezone fix, a storage migration) goes in a
|
|
192
192
|
**separate group**, reviewed and tested on its own, never inside a mechanical split commit.
|
|
193
|
-
13. **Tooling delegation & the ratchet**
|
|
193
|
+
13. **Tooling delegation & the ratchet** - the section that decides whether any of this survives.
|
|
194
194
|
- Classify each applied rule by its registry `enforcement`. For every `lint` / `format` /
|
|
195
195
|
`scan` rule, state the concrete mechanism and whether that toolchain exists in the repo
|
|
196
196
|
(`LintToolchain`). **When it does not exist, saying so is a finding**, ranked above most
|
|
197
197
|
individual violations it would have caught.
|
|
198
198
|
- Recommend **baseline-and-ratchet, never big-bang**: grandfather existing violations, surface
|
|
199
|
-
only new ones, drain the backlog behind a green build. Order the plan the same way
|
|
199
|
+
only new ones, drain the backlog behind a green build. Order the plan the same way - stop
|
|
200
200
|
the bleeding, then clean up.
|
|
201
201
|
- Custom lint rule identifiers **are** the registry IDs (`sec_01_no_plaintext_persistence`,
|
|
202
202
|
`mod_01_no_sibling_import`) so a violation points straight at the rule entry.
|
|
203
|
-
- **Linting is local-only by decision**
|
|
203
|
+
- **Linting is local-only by decision** - the skill's `references/lint-local.sh` with `references/swiftlint.draft.yml`, config
|
|
204
204
|
and baseline kept outside the repository; no committed config, no build phase, no CI job.
|
|
205
205
|
State this honestly in the plan rather than describing a gate that does not exist: a
|
|
206
206
|
pre-PR habit catches less than a wall, so the residual risk is real and belongs in the
|
|
207
|
-
report. Adopting it project-wide remains an open decision
|
|
207
|
+
report. Adopting it project-wide remains an open decision - record it as one so it stays
|
|
208
208
|
visible instead of quietly lapsing.
|
|
209
209
|
- Never imply the audit substitutes for the linter, or the linter for a gate. The audit finds
|
|
210
210
|
the debt once; the linter keeps a developer honest; only a gate stops it returning.
|
|
211
|
-
14. **Numeric exit criteria**
|
|
211
|
+
14. **Numeric exit criteria** - target and today's measured value side by side: lint violations
|
|
212
212
|
per 1k lines (<1) · public-surface ratio, public declarations ÷ externally consumed symbols
|
|
213
213
|
(≈1) · forbidden cross-module imports (0) · removability delta · files over threshold and
|
|
214
214
|
files >120 lines with no sectioning (0) · screens missing a manifest role (0) · escape-hatch
|
|
215
215
|
count `[CONC-03]` (flat or falling) · exception-marker count (flat or falling) · dead code (0)
|
|
216
216
|
· screens with logic and no mirrored tests (0) · unclassified sensitive symbols (0).
|
|
217
|
-
15. **Exception ledger**
|
|
217
|
+
15. **Exception ledger** - every `// standard:exception(<ID>)` in the module: rule · reason ·
|
|
218
218
|
expiry · expired yes/no.
|
|
219
|
-
16. **Open Questions**
|
|
220
|
-
17. **Suggested commit slicing**
|
|
221
|
-
18. **Scope guard**
|
|
219
|
+
16. **Open Questions** - Phase 2b dimensions with no majority, and unclassifiable symbols.
|
|
220
|
+
17. **Suggested commit slicing** - one commit per file-group / concern.
|
|
221
|
+
18. **Scope guard** - only this module is touched; definition of done is the module's own
|
|
222
222
|
verification path.
|
|
223
223
|
|
|
224
224
|
Then write a **separate one-page onboarding summary** beside the plan, aimed at a developer
|
|
225
225
|
joining next week rather than at whoever does the remediation: the ten always/never lines
|
|
226
226
|
specialised to this module, the screen file manifest, the placement ladder, the sensitive-data
|
|
227
|
-
classes in play, and the verification command. Nobody learns a codebase from a 40-finding audit
|
|
227
|
+
classes in play, and the verification command. Nobody learns a codebase from a 40-finding audit -
|
|
228
228
|
this page is the actual deliverable, and the audit is how you earn the right to write it
|
|
229
|
-
accurately. Derive it from `references/STANDARD.md
|
|
229
|
+
accurately. Derive it from `references/STANDARD.md section 0`, do not re-invent it.
|
|
230
230
|
|
|
231
231
|
Show a concise version of the plan to the user too.
|
|
232
232
|
|
|
233
|
-
## Phase 5
|
|
233
|
+
## Phase 5 - Hand off
|
|
234
234
|
|
|
235
235
|
`AskUserQuestion` (single-select, `outputLanguage`):
|
|
236
236
|
|
|
@@ -245,10 +245,10 @@ verifies, and commits only when asked. This routine ends at the handoff.
|
|
|
245
245
|
|
|
246
246
|
## Notes
|
|
247
247
|
|
|
248
|
-
- **Verification reality is per-module
|
|
248
|
+
- **Verification reality is per-module - establish it, do not assume it.** Some targets cannot be
|
|
249
249
|
compiled from the CLI (asset symbol generation, UIKit-dependent packages, a dependency failing
|
|
250
250
|
earlier in the graph); there, verification is Xcode and a manifest dump validates the manifest
|
|
251
|
-
only. Never trust a piped build's exit code
|
|
251
|
+
only. Never trust a piped build's exit code - a pipe reports the tail's status. Record the
|
|
252
252
|
module's actual verification command in the plan.
|
|
253
253
|
- One module per run. Several modules means several runs and several plans, never one
|
|
254
254
|
cross-module churn commit.
|
|
@@ -633,7 +633,8 @@ Entries are objects (not bare strings). The schema `recentProjects.items` is a `
|
|
|
633
633
|
|
|
634
634
|
**7g. Skip / degrade rules**
|
|
635
635
|
|
|
636
|
-
- Source failure (network, VPN, 401, rate-limit) skips that source
|
|
636
|
+
- Source failure (network, VPN, 401, rate-limit) skips that source; the others still run. Failure is recorded in `serviceStatus.{service} = { ok: false, checkedAt: <now>, reason: <short> }` so Phase 0 can also read that cache.
|
|
637
|
+
- **Report the skip, do not swallow it.** Setup is where the user is actively configuring things, so a source that could not be reached is exactly what they need to hear about, and the reason decides their next move: a 401 means refresh that token, no response at all on a corporate host means connect the VPN, and a missing `global.hosts.<service>` means the token has nowhere to point. Print one line per skipped source with its classification (see `refs/keychain.md` Rule 2) before moving on. A discovery pass that quietly returned three repos out of forty reads as "you only have three repos".
|
|
637
638
|
- If all three sources yield zero hits, Step 5 is a no-op - the picker in Phase 0 falls back to manual path entry.
|
|
638
639
|
- Discovery is **never destructive**: it never modifies git state, never writes outside `PREFS_FILE`.
|
|
639
640
|
- Step 5 is idempotent. Re-running it refreshes `recentProjects` and `serviceStatus` but preserves user-curated entries unless explicitly deselected.
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# credential-inventory.sh - what the pipeline can reach right now, and what it cannot.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS EXISTS
|
|
5
|
+
#
|
|
6
|
+
# A run asked the user to paste a Crashlytics stack trace by hand while a Firebase
|
|
7
|
+
# service-account JSON sat in the Keychain, mapped as `firebase`, fully valid. Nothing
|
|
8
|
+
# was broken - the pipeline simply never asked itself "do I already hold a credential
|
|
9
|
+
# that answers this?" before asking the user. From the user's side that is worse than a
|
|
10
|
+
# failure: they had supplied the key precisely so this would not happen.
|
|
11
|
+
#
|
|
12
|
+
# One command, so there is no excuse for asking blind. Run it before any question that
|
|
13
|
+
# requests data an external system holds, and let the answer shape the question:
|
|
14
|
+
#
|
|
15
|
+
# - credential present -> ask for the *pointer* (the issue URL), not the payload,
|
|
16
|
+
# and say you will fetch it
|
|
17
|
+
# - credential missing -> say which logical key is unmapped and offer the Save Flow
|
|
18
|
+
# - credential dead -> say it is expired and offer to refresh it
|
|
19
|
+
#
|
|
20
|
+
# SAFETY
|
|
21
|
+
#
|
|
22
|
+
# Values are never printed, never stored in a variable that reaches stdout, and never
|
|
23
|
+
# logged. Presence is probed through `credential-store.sh get` with stdout discarded, so
|
|
24
|
+
# the secret goes to /dev/null and only the exit status is read.
|
|
25
|
+
#
|
|
26
|
+
# Usage:
|
|
27
|
+
# credential-inventory.sh # human-readable table
|
|
28
|
+
# credential-inventory.sh --json # machine-readable, for state files
|
|
29
|
+
# credential-inventory.sh --probe # also verify each configured service answers
|
|
30
|
+
# credential-inventory.sh --key firebase # single logical key, exit 0 present / 1 not
|
|
31
|
+
#
|
|
32
|
+
# PRESENT IS NOT THE SAME AS WORKING
|
|
33
|
+
#
|
|
34
|
+
# Without --probe this reports what is configured. With --probe each configured
|
|
35
|
+
# credential makes one cheap authenticated request, and the verdict distinguishes the
|
|
36
|
+
# three failures that need three different user actions:
|
|
37
|
+
#
|
|
38
|
+
# reachable the service answered and accepted the credential
|
|
39
|
+
# auth-rejected 401/403 - the credential is dead, the user must refresh it
|
|
40
|
+
# unreachable no response at all - on a corporate host, almost always the VPN
|
|
41
|
+
# no-host-configured a token exists but its host was never recorded in preferences
|
|
42
|
+
# well-formed structurally valid, liveness not checkable without more input
|
|
43
|
+
# not-probeable no cheap probe exists for this credential type
|
|
44
|
+
#
|
|
45
|
+
# Only configured credentials are probed. An unmapped key is a capability the user chose
|
|
46
|
+
# not to enable, not a problem to report.
|
|
47
|
+
#
|
|
48
|
+
# Exit codes: 0 = inventory produced (or the queried key is present), 1 = queried key
|
|
49
|
+
# absent, 3 = usage error.
|
|
50
|
+
|
|
51
|
+
set -uo pipefail
|
|
52
|
+
|
|
53
|
+
PREFS="${MULTI_AGENT_PREFS:-$HOME/.claude/multi-agent-preferences.json}"
|
|
54
|
+
|
|
55
|
+
STORE=""
|
|
56
|
+
for c in "$HOME/.claude/lib/credential-store.sh" "$HOME/.copilot/lib/credential-store.sh" \
|
|
57
|
+
"$HOME/.codex/lib/credential-store.sh" "$(dirname "$0")/credential-store.sh"; do
|
|
58
|
+
[ -f "$c" ] && { STORE="$c"; break; }
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
MODE="table"
|
|
62
|
+
QUERY=""
|
|
63
|
+
PROBE=0
|
|
64
|
+
TIMEOUT="${MULTI_AGENT_PROBE_TIMEOUT:-6}"
|
|
65
|
+
while [ $# -gt 0 ]; do
|
|
66
|
+
case "$1" in
|
|
67
|
+
--json) MODE="json"; shift ;;
|
|
68
|
+
--probe) PROBE=1; shift ;;
|
|
69
|
+
--key) QUERY="${2:-}"; shift 2 || shift ;;
|
|
70
|
+
-h|--help)
|
|
71
|
+
echo "usage: $0 [--json] [--probe] [--key <logical-key>]" >&2; exit 3 ;;
|
|
72
|
+
*) echo "ERR: unexpected arg $1" >&2; exit 3 ;;
|
|
73
|
+
esac
|
|
74
|
+
done
|
|
75
|
+
|
|
76
|
+
pref() { # pref <dotted.path> -> value or empty
|
|
77
|
+
[ -f "$PREFS" ] || return 0
|
|
78
|
+
python3 - "$PREFS" "$1" <<'PYPREF' 2>/dev/null
|
|
79
|
+
import json, sys
|
|
80
|
+
try:
|
|
81
|
+
d = json.load(open(sys.argv[1]))
|
|
82
|
+
except Exception:
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
for k in sys.argv[2].split("."):
|
|
85
|
+
if not isinstance(d, dict):
|
|
86
|
+
sys.exit(0)
|
|
87
|
+
d = d.get(k)
|
|
88
|
+
if d is None:
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
print(d if not isinstance(d, (dict, list)) else "")
|
|
91
|
+
PYPREF
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# An authenticated request whose credential never appears in argv.
|
|
95
|
+
#
|
|
96
|
+
# `curl -H "Authorization: Bearer $TOK"` puts the secret in the process command line,
|
|
97
|
+
# where any `ps` on the machine can read it. `--config -` takes both the header and the
|
|
98
|
+
# URL from stdin instead, so the token exists only in the pipe. Same reason the Vercel
|
|
99
|
+
# wrapper refuses a `--token=` argv.
|
|
100
|
+
#
|
|
101
|
+
# probe_http <url> <header-name> <header-value> -> prints the HTTP status
|
|
102
|
+
probe_http() {
|
|
103
|
+
local url="$1" hname="$2" hval="$3" out
|
|
104
|
+
# No `|| echo 000` here: curl already writes `000` through write-out when it never
|
|
105
|
+
# got a response, and exits non-zero as well. Appending produced `000000`, which fell
|
|
106
|
+
# through classify_status into a bogus `unexpected-000000` verdict for what was simply
|
|
107
|
+
# a closed VPN.
|
|
108
|
+
out=$(printf 'url = "%s"\nheader = "%s: %s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
|
|
109
|
+
"$url" "$hname" "$hval" "$TIMEOUT" | curl --config - 2>/dev/null)
|
|
110
|
+
printf '%s' "${out:-000}"
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# probe_basic <url> <user> <pass> -> prints the HTTP status
|
|
114
|
+
probe_basic() {
|
|
115
|
+
local url="$1" u="$2" p="$3" out
|
|
116
|
+
out=$(printf 'url = "%s"\nuser = "%s:%s"\nsilent\noutput = "/dev/null"\nwrite-out = "%%{http_code}"\nmax-time = %s\n' \
|
|
117
|
+
"$url" "$u" "$p" "$TIMEOUT" | curl --config - 2>/dev/null)
|
|
118
|
+
printf '%s' "${out:-000}"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Map an HTTP status onto the vocabulary the pipeline reasons about. `000` is curl's
|
|
122
|
+
# "never got a response" - DNS failure, refused connection, timeout - which on a
|
|
123
|
+
# corporate host is almost always a closed VPN, and is a completely different user
|
|
124
|
+
# action from a rejected credential.
|
|
125
|
+
classify_status() {
|
|
126
|
+
case "$1" in
|
|
127
|
+
2??) echo "reachable" ;;
|
|
128
|
+
401|403) echo "auth-rejected" ;;
|
|
129
|
+
404) echo "reachable" ;; # endpoint answered; the probe path may just not exist
|
|
130
|
+
000|"") echo "unreachable" ;;
|
|
131
|
+
5??) echo "server-error" ;;
|
|
132
|
+
# Anything that is not a three-digit status means the probe itself misbehaved.
|
|
133
|
+
# Saying so beats inventing a service verdict from a malformed value.
|
|
134
|
+
*) echo "probe-error" ;;
|
|
135
|
+
esac
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Reachability for one logical key. Never prints a credential; the value is piped
|
|
139
|
+
# straight into curl's stdin config and discarded.
|
|
140
|
+
#
|
|
141
|
+
# Hosts always come from preferences - a self-hosted Jira / Bitbucket / Confluence /
|
|
142
|
+
# Fortify / Graylog address is deployment-specific and must never be baked in. Only
|
|
143
|
+
# genuinely global public APIs are named literally. Host keys live at
|
|
144
|
+
# `global.hosts.<service>` per prefs.schema.json - an earlier version of this probe
|
|
145
|
+
# guessed `global.<service>Host` and reported no-host-configured for every
|
|
146
|
+
# self-hosted service the user had actually configured.
|
|
147
|
+
probe_one() {
|
|
148
|
+
local key="$1" tok host status
|
|
149
|
+
tok=$(bash "$STORE" get "$key" 2>/dev/null) || { echo "no-credential"; return; }
|
|
150
|
+
[ -n "$tok" ] || { echo "no-credential"; return; }
|
|
151
|
+
|
|
152
|
+
case "$key" in
|
|
153
|
+
jira)
|
|
154
|
+
host=$(pref global.hosts.jira)
|
|
155
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
156
|
+
status=$(probe_http "https://${host}/rest/api/2/myself" "Authorization" "Bearer $tok") ;;
|
|
157
|
+
confluence)
|
|
158
|
+
host=$(pref global.hosts.confluence)
|
|
159
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
160
|
+
status=$(probe_http "https://${host}/rest/api/user/current" "Authorization" "Bearer $tok") ;;
|
|
161
|
+
bitbucket_token)
|
|
162
|
+
host=$(pref global.hosts.bitbucket)
|
|
163
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
164
|
+
local bbuser bbkey
|
|
165
|
+
bbkey=$(pref global.keychainMapping.bitbucket_user)
|
|
166
|
+
bbuser=$([ -n "$bbkey" ] && bash "$STORE" get bitbucket_user 2>/dev/null || echo "")
|
|
167
|
+
if [ -n "$bbuser" ]; then
|
|
168
|
+
status=$(probe_basic "https://${host}/rest/api/1.0/repos?limit=1" "$bbuser" "$tok")
|
|
169
|
+
else
|
|
170
|
+
status=$(probe_http "https://${host}/rest/api/1.0/repos?limit=1" "Authorization" "Bearer $tok")
|
|
171
|
+
fi ;;
|
|
172
|
+
github)
|
|
173
|
+
status=$(probe_http "https://api.github.com/user" "Authorization" "Bearer $tok") ;;
|
|
174
|
+
figma)
|
|
175
|
+
status=$(probe_http "https://api.figma.com/v1/me" "X-Figma-Token" "$tok") ;;
|
|
176
|
+
figma_mcp)
|
|
177
|
+
# An OAuth token for the Figma MCP server, not a REST PAT. Sending it to
|
|
178
|
+
# api.figma.com returns 403 for a perfectly healthy token, which is worse than
|
|
179
|
+
# not probing: it would send the user to regenerate something that works.
|
|
180
|
+
# Liveness for this one belongs to figma-mcp-refresh.sh, which owns the grant.
|
|
181
|
+
printf '%s' "$tok" | grep -q . && echo "not-probeable" || echo "malformed"
|
|
182
|
+
return ;;
|
|
183
|
+
npm)
|
|
184
|
+
status=$(probe_http "https://registry.npmjs.org/-/whoami" "Authorization" "Bearer $tok") ;;
|
|
185
|
+
fortify)
|
|
186
|
+
host=$(pref global.hosts.fortify)
|
|
187
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
188
|
+
status=$(probe_http "https://${host}/api/v1/projects?limit=1" "Authorization" "Bearer $tok") ;;
|
|
189
|
+
graylog)
|
|
190
|
+
host=$(pref global.hosts.graylog)
|
|
191
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
192
|
+
status=$(probe_http "https://${host}/api/system" "Authorization" "Bearer $tok") ;;
|
|
193
|
+
jenkins)
|
|
194
|
+
host=$(pref global.hosts.jenkins)
|
|
195
|
+
[ -n "$host" ] || { echo "no-host-configured"; return; }
|
|
196
|
+
status=$(probe_http "https://${host}/api/json" "Authorization" "Bearer $tok") ;;
|
|
197
|
+
firebase)
|
|
198
|
+
# A service-account JSON, not a bearer token: an OAuth exchange would be needed
|
|
199
|
+
# to reach Crashlytics, and the fetcher does that per issue. Verifying the shape
|
|
200
|
+
# is honest about what is known - well-formed, not proven reachable.
|
|
201
|
+
if printf '%s' "$tok" | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d.get("project_id") and d.get("private_key") else 1)' 2>/dev/null; then
|
|
202
|
+
echo "well-formed"; return
|
|
203
|
+
fi
|
|
204
|
+
echo "malformed"; return ;;
|
|
205
|
+
appstore_connect_private_key)
|
|
206
|
+
printf '%s' "$tok" | grep -q "BEGIN PRIVATE KEY" && echo "well-formed" || echo "malformed"
|
|
207
|
+
return ;;
|
|
208
|
+
*)
|
|
209
|
+
echo "not-probeable"; return ;;
|
|
210
|
+
esac
|
|
211
|
+
classify_status "$status"
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# What each logical key unlocks, in the pipeline's own terms. This is the column that
|
|
215
|
+
# turns an inventory into an actionable question: "I hold `firebase`, so give me the
|
|
216
|
+
# issue URL and I will pull the stack trace" is only sayable if the capability is
|
|
217
|
+
# written down somewhere.
|
|
218
|
+
capability_of() {
|
|
219
|
+
case "$1" in
|
|
220
|
+
jira) echo "read the ticket, its comments and its linked issues; post the Phase 7 comment" ;;
|
|
221
|
+
bitbucket_token) echo "read the repo, open and update pull requests" ;;
|
|
222
|
+
bitbucket_user) echo "identify the PR author (paired with bitbucket_token)" ;;
|
|
223
|
+
github) echo "read issues, open pull requests, read Actions runs" ;;
|
|
224
|
+
confluence) echo "read linked pages and publish the analysis document" ;;
|
|
225
|
+
firebase) echo "pull a Crashlytics issue: stack frames, affected versions, device spread (needs the issue URL)" ;;
|
|
226
|
+
fortify) echo "pull a static-analysis finding and its remediation guidance" ;;
|
|
227
|
+
graylog) echo "pull request logs by transaction or conversation id (advisory)" ;;
|
|
228
|
+
figma|figma_mcp) echo "fetch design context, screenshots and Code Connect mappings" ;;
|
|
229
|
+
jenkins) echo "read build results" ;;
|
|
230
|
+
npm) echo "publish to the npm registry" ;;
|
|
231
|
+
appstore_connect_key_id|appstore_connect_issuer_id|appstore_connect_private_key)
|
|
232
|
+
echo "validate an archive against App Store rules before submission" ;;
|
|
233
|
+
claude_oauth_token|claude_oauth_token_fallback)
|
|
234
|
+
echo "run headless review and analysis passes" ;;
|
|
235
|
+
*) echo "(no capability recorded for this key)" ;;
|
|
236
|
+
esac
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
mapping_keys() {
|
|
240
|
+
[ -f "$PREFS" ] || return 0
|
|
241
|
+
python3 - "$PREFS" <<'PY' 2>/dev/null
|
|
242
|
+
import json, sys
|
|
243
|
+
try:
|
|
244
|
+
p = json.load(open(sys.argv[1]))
|
|
245
|
+
except Exception:
|
|
246
|
+
sys.exit(0)
|
|
247
|
+
m = (p.get("global") or {}).get("keychainMapping") or {}
|
|
248
|
+
for k, v in m.items():
|
|
249
|
+
print(f"{k}\t{'1' if v else '0'}")
|
|
250
|
+
PY
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# Presence probe. The value lands in /dev/null; only the exit status is observed.
|
|
254
|
+
probe() {
|
|
255
|
+
[ -n "$STORE" ] || return 2
|
|
256
|
+
bash "$STORE" get "$1" >/dev/null 2>&1
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if [ -n "$QUERY" ]; then
|
|
260
|
+
probe "$QUERY"
|
|
261
|
+
rc=$?
|
|
262
|
+
case "$rc" in
|
|
263
|
+
0)
|
|
264
|
+
if [ "$PROBE" = "1" ]; then
|
|
265
|
+
echo "$QUERY: present, $(probe_one "$QUERY") - can $(capability_of "$QUERY")"
|
|
266
|
+
else
|
|
267
|
+
echo "$QUERY: present - can $(capability_of "$QUERY")"
|
|
268
|
+
fi
|
|
269
|
+
exit 0 ;;
|
|
270
|
+
2) echo "$QUERY: no credential helper on this host" >&2; exit 1 ;;
|
|
271
|
+
*) echo "$QUERY: NOT AVAILABLE - onboard it via /multi-agent:setup before relying on it" >&2; exit 1 ;;
|
|
272
|
+
esac
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
ROWS=""
|
|
276
|
+
while IFS=$'\t' read -r key mapped; do
|
|
277
|
+
[ -z "$key" ] && continue
|
|
278
|
+
if [ "$mapped" != "1" ]; then
|
|
279
|
+
state="unmapped"
|
|
280
|
+
reach="not-probed"
|
|
281
|
+
elif probe "$key"; then
|
|
282
|
+
state="present"
|
|
283
|
+
# Only configured credentials are probed. A key the user never onboarded is not a
|
|
284
|
+
# problem to report - it is a capability they chose not to enable.
|
|
285
|
+
reach=$([ "$PROBE" = "1" ] && probe_one "$key" || echo "not-probed")
|
|
286
|
+
else
|
|
287
|
+
state="mapped-but-missing"
|
|
288
|
+
reach="not-probed"
|
|
289
|
+
fi
|
|
290
|
+
ROWS="${ROWS}${key}\t${state}\t${reach}\t$(capability_of "$key")\n"
|
|
291
|
+
done < <(mapping_keys)
|
|
292
|
+
|
|
293
|
+
if [ -z "$ROWS" ]; then
|
|
294
|
+
if [ "$MODE" = "json" ]; then
|
|
295
|
+
echo '{"status":"empty","reason":"no keychainMapping in preferences","credentials":[]}'
|
|
296
|
+
else
|
|
297
|
+
echo "no keychainMapping found in $PREFS - run /multi-agent:setup" >&2
|
|
298
|
+
fi
|
|
299
|
+
exit 0
|
|
300
|
+
fi
|
|
301
|
+
|
|
302
|
+
if [ "$MODE" = "json" ]; then
|
|
303
|
+
# The rows travel through a temp file, not a pipe. `python3 - <<EOF` takes its SCRIPT
|
|
304
|
+
# from stdin, so a piped payload never reaches sys.stdin: --json came back with empty
|
|
305
|
+
# arrays while the table mode looked correct. Caught by smoke-credential-awareness.
|
|
306
|
+
ROWS_FILE="$(mktemp)"
|
|
307
|
+
trap 'rm -f "$ROWS_FILE"' EXIT
|
|
308
|
+
printf '%b' "$ROWS" > "$ROWS_FILE"
|
|
309
|
+
python3 - "$ROWS_FILE" <<'PYJSON'
|
|
310
|
+
import json, sys
|
|
311
|
+
rows = []
|
|
312
|
+
with open(sys.argv[1]) as fh:
|
|
313
|
+
for line in fh:
|
|
314
|
+
line = line.rstrip("\n")
|
|
315
|
+
if not line:
|
|
316
|
+
continue
|
|
317
|
+
parts = line.split("\t")
|
|
318
|
+
if len(parts) < 4:
|
|
319
|
+
continue
|
|
320
|
+
rows.append({
|
|
321
|
+
"logical": parts[0],
|
|
322
|
+
"state": parts[1],
|
|
323
|
+
"reachability": parts[2],
|
|
324
|
+
"capability": parts[3],
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
# `usable` is presence only, so a caller that never probed still gets a meaningful
|
|
328
|
+
# answer. `reachable` is the stronger claim and exists only after --probe.
|
|
329
|
+
OK_REACH = {"reachable", "well-formed"}
|
|
330
|
+
BLOCKED = {"auth-rejected", "malformed"}
|
|
331
|
+
probed = [r for r in rows if r["reachability"] != "not-probed"]
|
|
332
|
+
print(json.dumps({
|
|
333
|
+
"status": "ok",
|
|
334
|
+
"probed": bool(probed),
|
|
335
|
+
"usable": [r["logical"] for r in rows if r["state"] == "present"],
|
|
336
|
+
"needsAttention": [r["logical"] for r in rows if r["state"] != "present"],
|
|
337
|
+
# Separated on purpose: a dead token is the user's to refresh, an unreachable host
|
|
338
|
+
# is usually a closed VPN, and a missing host is a setup gap. Collapsing them into
|
|
339
|
+
# one "failed" bucket is what made the pipeline ask blind questions.
|
|
340
|
+
"reachable": [r["logical"] for r in probed if r["reachability"] in OK_REACH],
|
|
341
|
+
"authRejected": [r["logical"] for r in probed if r["reachability"] in BLOCKED],
|
|
342
|
+
"unreachable": [r["logical"] for r in probed if r["reachability"] == "unreachable"],
|
|
343
|
+
"noHostConfigured": [r["logical"] for r in probed if r["reachability"] == "no-host-configured"],
|
|
344
|
+
"credentials": rows,
|
|
345
|
+
}, indent=2))
|
|
346
|
+
PYJSON
|
|
347
|
+
else
|
|
348
|
+
printf '%b' "$ROWS" | awk -F'\t' '
|
|
349
|
+
BEGIN { printf "%-28s %-20s %-20s %s\n", "LOGICAL KEY", "STATE", "REACHABILITY", "UNLOCKS" }
|
|
350
|
+
{ printf "%-28s %-20s %-20s %s\n", $1, $2, $3, $4 }'
|
|
351
|
+
fi
|