@tiphys/kernel 0.0.0 → 0.2.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.
Files changed (207) hide show
  1. package/AGENTS.md +663 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +299 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +92 -0
  11. package/dist/src/adapters/load.d.ts +202 -0
  12. package/dist/src/adapters/load.js +440 -0
  13. package/dist/src/brief.d.ts +32 -0
  14. package/dist/src/brief.js +66 -0
  15. package/dist/src/checklists.d.ts +169 -0
  16. package/dist/src/checklists.js +310 -0
  17. package/dist/src/checks.d.ts +1539 -0
  18. package/dist/src/checks.js +5025 -0
  19. package/dist/src/cli.d.ts +9 -0
  20. package/dist/src/cli.js +68 -0
  21. package/dist/src/commands/brief.d.ts +92 -0
  22. package/dist/src/commands/brief.js +343 -0
  23. package/dist/src/commands/checklist.d.ts +42 -0
  24. package/dist/src/commands/checklist.js +168 -0
  25. package/dist/src/commands/cutover.d.ts +35 -0
  26. package/dist/src/commands/cutover.js +448 -0
  27. package/dist/src/commands/doctor.d.ts +264 -0
  28. package/dist/src/commands/doctor.js +1546 -0
  29. package/dist/src/commands/gates.d.ts +9 -0
  30. package/dist/src/commands/gates.js +360 -0
  31. package/dist/src/commands/init.d.ts +30 -0
  32. package/dist/src/commands/init.js +177 -0
  33. package/dist/src/commands/lock.d.ts +34 -0
  34. package/dist/src/commands/lock.js +340 -0
  35. package/dist/src/commands/mode.d.ts +40 -0
  36. package/dist/src/commands/mode.js +134 -0
  37. package/dist/src/commands/next.d.ts +130 -0
  38. package/dist/src/commands/next.js +597 -0
  39. package/dist/src/commands/plan.d.ts +20 -0
  40. package/dist/src/commands/plan.js +105 -0
  41. package/dist/src/commands/pool.d.ts +1 -0
  42. package/dist/src/commands/pool.js +139 -0
  43. package/dist/src/commands/resume.d.ts +1 -0
  44. package/dist/src/commands/resume.js +88 -0
  45. package/dist/src/commands/spawn.d.ts +1 -0
  46. package/dist/src/commands/spawn.js +195 -0
  47. package/dist/src/commands/status.d.ts +21 -0
  48. package/dist/src/commands/status.js +122 -0
  49. package/dist/src/commands/sync.d.ts +47 -0
  50. package/dist/src/commands/sync.js +341 -0
  51. package/dist/src/commands/teardown.d.ts +1 -0
  52. package/dist/src/commands/teardown.js +87 -0
  53. package/dist/src/commands/tuition.d.ts +21 -0
  54. package/dist/src/commands/tuition.js +218 -0
  55. package/dist/src/commands/validate.d.ts +78 -0
  56. package/dist/src/commands/validate.js +430 -0
  57. package/dist/src/commands/watch.d.ts +1 -0
  58. package/dist/src/commands/watch.js +172 -0
  59. package/dist/src/cutover.d.ts +584 -0
  60. package/dist/src/cutover.js +1444 -0
  61. package/dist/src/exclusion.d.ts +389 -0
  62. package/dist/src/exclusion.js +843 -0
  63. package/dist/src/exec/env.d.ts +278 -0
  64. package/dist/src/exec/env.js +334 -0
  65. package/dist/src/fleet.d.ts +223 -0
  66. package/dist/src/fleet.js +298 -0
  67. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  68. package/dist/src/gates/adapters/http-json.js +283 -0
  69. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  70. package/dist/src/gates/adapters/migrations-command.js +373 -0
  71. package/dist/src/gates/citations.d.ts +408 -0
  72. package/dist/src/gates/citations.js +1169 -0
  73. package/dist/src/gates/coverage.d.ts +375 -0
  74. package/dist/src/gates/coverage.js +836 -0
  75. package/dist/src/gates/credentials.d.ts +233 -0
  76. package/dist/src/gates/credentials.js +752 -0
  77. package/dist/src/gates/deploy.d.ts +1 -0
  78. package/dist/src/gates/deploy.js +33 -0
  79. package/dist/src/gates/gate-classes.d.ts +56 -0
  80. package/dist/src/gates/gate-classes.js +633 -0
  81. package/dist/src/gates/manifest.d.ts +99 -0
  82. package/dist/src/gates/manifest.js +208 -0
  83. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  84. package/dist/src/gates/merge-preconditions.js +932 -0
  85. package/dist/src/gates/migrations.d.ts +1 -0
  86. package/dist/src/gates/migrations.js +36 -0
  87. package/dist/src/gates/pin.d.ts +114 -0
  88. package/dist/src/gates/pin.js +154 -0
  89. package/dist/src/gates/red-witness.d.ts +22 -0
  90. package/dist/src/gates/red-witness.js +482 -0
  91. package/dist/src/gates/release.d.ts +283 -0
  92. package/dist/src/gates/release.js +820 -0
  93. package/dist/src/gates/result.d.ts +116 -0
  94. package/dist/src/gates/result.js +91 -0
  95. package/dist/src/gates/run.d.ts +614 -0
  96. package/dist/src/gates/run.js +1614 -0
  97. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  98. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  99. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  100. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  101. package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
  102. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  103. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  104. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  105. package/dist/src/gates/scope.d.ts +131 -0
  106. package/dist/src/gates/scope.js +1018 -0
  107. package/dist/src/gates/suite.d.ts +217 -0
  108. package/dist/src/gates/suite.js +968 -0
  109. package/dist/src/gates/validate.d.ts +121 -0
  110. package/dist/src/gates/validate.js +414 -0
  111. package/dist/src/hooks.d.ts +84 -0
  112. package/dist/src/hooks.js +125 -0
  113. package/dist/src/index.d.ts +31 -0
  114. package/dist/src/index.js +30 -0
  115. package/dist/src/liveness.d.ts +321 -0
  116. package/dist/src/liveness.js +396 -0
  117. package/dist/src/lock.d.ts +256 -0
  118. package/dist/src/lock.js +792 -0
  119. package/dist/src/model-resolution.d.ts +159 -0
  120. package/dist/src/model-resolution.js +307 -0
  121. package/dist/src/modes.d.ts +149 -0
  122. package/dist/src/modes.js +258 -0
  123. package/dist/src/path-identity.d.ts +34 -0
  124. package/dist/src/path-identity.js +48 -0
  125. package/dist/src/plan.d.ts +73 -0
  126. package/dist/src/plan.js +153 -0
  127. package/dist/src/pool.d.ts +326 -0
  128. package/dist/src/pool.js +988 -0
  129. package/dist/src/roles.d.ts +461 -0
  130. package/dist/src/roles.js +776 -0
  131. package/dist/src/spawn.d.ts +482 -0
  132. package/dist/src/spawn.js +1003 -0
  133. package/dist/src/status.d.ts +116 -0
  134. package/dist/src/status.js +148 -0
  135. package/dist/src/task.d.ts +504 -0
  136. package/dist/src/task.js +307 -0
  137. package/dist/src/teardown.d.ts +39 -0
  138. package/dist/src/teardown.js +422 -0
  139. package/dist/src/tuition.d.ts +159 -0
  140. package/dist/src/tuition.js +311 -0
  141. package/dist/src/validate.d.ts +263 -0
  142. package/dist/src/validate.js +742 -0
  143. package/dist/src/version.d.ts +3 -0
  144. package/dist/src/version.js +38 -0
  145. package/dist/src/watcher.d.ts +275 -0
  146. package/dist/src/watcher.js +849 -0
  147. package/dist/src/witness/run.d.ts +299 -0
  148. package/dist/src/witness/run.js +1373 -0
  149. package/dist/src/witness/spec.d.ts +270 -0
  150. package/dist/src/witness/spec.js +475 -0
  151. package/dist/tsconfig.src.tsbuildinfo +1 -0
  152. package/gate-registry.yaml +526 -0
  153. package/gates.manifest.json +257 -0
  154. package/package.json +72 -3
  155. package/role-model-config.yaml +88 -0
  156. package/roles/README.md +128 -0
  157. package/roles/_shared-dispatch-contract.md +87 -0
  158. package/roles/adversarial-plan-reviewer.md +80 -0
  159. package/roles/clean-room-reviewer.md +140 -0
  160. package/roles/implementer.md +463 -0
  161. package/roles/investigator.md +138 -0
  162. package/roles/plan-writer.md +95 -0
  163. package/schemas/README.md +82 -0
  164. package/schemas/assurance-modes.schema.json +264 -0
  165. package/schemas/charter.schema.json +185 -0
  166. package/schemas/checklist.schema.json +114 -0
  167. package/schemas/cutover-state.schema.json +64 -0
  168. package/schemas/decision-record.schema.json +88 -0
  169. package/schemas/executor-record.schema.json +36 -0
  170. package/schemas/final-report.schema.json +90 -0
  171. package/schemas/finding.schema.json +106 -0
  172. package/schemas/gate-registry.schema.json +260 -0
  173. package/schemas/mechanism-index.schema.json +94 -0
  174. package/schemas/model-resolution.schema.json +362 -0
  175. package/schemas/plan.schema.json +300 -0
  176. package/schemas/report.schema.json +579 -0
  177. package/schemas/role-brief.schema.json +105 -0
  178. package/schemas/role-model-config.schema.json +90 -0
  179. package/schemas/status-line.schema.json +40 -0
  180. package/schemas/tuition.schema.json +191 -0
  181. package/schemas/verdict.schema.json +295 -0
  182. package/schemas/work-history.schema.json +183 -0
  183. package/schemas/write-bypass.schema.json +69 -0
  184. package/templates/charter.example.yaml +54 -0
  185. package/templates/decision-record.example.yaml +27 -0
  186. package/templates/final-report.example.yaml +80 -0
  187. package/templates/plan.example.yaml +87 -0
  188. package/templates/report.example.yaml +236 -0
  189. package/templates/warnings.md +74 -0
  190. package/templates/work-history.example.yaml +185 -0
  191. package/tuition/README.md +76 -0
  192. package/tuition/T-001.yaml +48 -0
  193. package/tuition/T-002.yaml +51 -0
  194. package/tuition/T-003.yaml +100 -0
  195. package/tuition/T-004.yaml +52 -0
  196. package/tuition/T-005.yaml +72 -0
  197. package/tuition/T-006.yaml +81 -0
  198. package/tuition/T-007.yaml +56 -0
  199. package/tuition/T-008.yaml +111 -0
  200. package/tuition/T-009.yaml +50 -0
  201. package/tuition/T-015.yaml +36 -0
  202. package/tuition/T-016.yaml +36 -0
  203. package/tuition/T-017.yaml +46 -0
  204. package/tuition/T-018.yaml +84 -0
  205. package/tuition/T-021.yaml +40 -0
  206. package/tuition/T-022.yaml +36 -0
  207. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,752 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, writeFileSync } from "node:fs";
3
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { pathsIdentifySameObject } from "../path-identity.js";
6
+ import { buildChildEnv, permittedChildEnvNames, } from "../exec/env.js";
7
+ import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
8
+ import { exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
9
+ /**
10
+ * THE CREDENTIAL GATES (kernel plan M2, M2-P8 step 6).
11
+ *
12
+ * Two registered entries, both invoked through the M2-P1 gate subprocess
13
+ * contract (`node src/gates/credentials.ts <gate-id> --result <path>
14
+ * --evidence <dir>`), each writing exactly one GateResult.
15
+ *
16
+ * `credential-scrub` (required, offline). Makes "implementers never create
17
+ * PRs" PROBED rather than believed: it constructs a child environment with
18
+ * the SAME `buildChildEnv` the executor uses (one mechanism, per T-005,
19
+ * never a second implementation) and then probes, FROM INSIDE that
20
+ * environment, every source a pull-request-capable credential could be
21
+ * resolved from. `units` is the number of SOURCES PROBED, never the number
22
+ * of variable names checked: under an allowlist no excluded name can
23
+ * survive by construction, so a name count is a tautology that grows by
24
+ * adding names and measures nothing (M2R-004).
25
+ *
26
+ * THE DERIVED DENYLIST TRIPWIRE IN THIS MODULE, AND WHY IT IS PERMITTED
27
+ * HERE, AND EXACTLY WHAT IT DOES AND DOES NOT COVER.
28
+ * The environment-source probe carries a bounded, allowlist-INDEPENDENT
29
+ * tripwire: it reddens if a documented credential- or code-execution-
30
+ * capable variable is present in the constructed child, even if some
31
+ * future edit puts one on the allowlist in src/exec/env.ts. MECHANISMS.md's
32
+ * row on denylists allows exactly this shape: "where a denylist is
33
+ * unavoidable, DERIVE it by walking the consuming program's closed
34
+ * documented vocabulary once, publishing the walk." The walk covers the
35
+ * vocabularies of the programs a scrubbed child actually runs:
36
+ *
37
+ * - gh: `gh help environment` documents the token variables as GH_TOKEN,
38
+ * GITHUB_TOKEN, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN
39
+ * (GH_TOKEN_VARIABLES).
40
+ * - git, ssh, node/loader/shell: the askpass, ssh-exec, proxy, config-
41
+ * injection and code-execution variables git-config(1), git(1),
42
+ * ssh(1), node(1) and bash(1) document (DANGEROUS_ENV_VOCABULARY;
43
+ * see that constant for the per-name source).
44
+ *
45
+ * A SECOND ALLOWLIST-INDEPENDENT TRIPWIRE WAS ADDED IN M4-P29, and its
46
+ * absence was a measured defect rather than an omission: this gate greened
47
+ * `HTTPS_PROXY`, the one name M4-P8 measured as the difference between
48
+ * HTTP 403 and HTTP 200 against `api.github.com/user` from inside a
49
+ * scrubbed child. The walk is `EGRESS_ENV_VOCABULARY` below; read its
50
+ * comment for what the addition does and does NOT close, because the
51
+ * per-invocation extension route stays outside this gate's view.
52
+ *
53
+ * WHY THIS IS A TRIPWIRE, NOT THE SCRUB. The scrub is the allowlist in
54
+ * src/exec/env.ts: nothing outside it can appear in a child by
55
+ * construction, so the allowlist is the real defense. This tripwire is a
56
+ * BOUNDED denylist and cannot enumerate every dangerous name that any
57
+ * program will ever read. What it buys is that "an allowlist widened by an
58
+ * implementer to turn a red gate green" (this phase's declared hazard)
59
+ * costs a red for every name in the walked vocabulary, not just gh's four
60
+ * tokens. A name outside the vocabulary that is also admitted to the
61
+ * allowlist would pass the environment probe green; that residue is the
62
+ * allowlist's responsibility, and this comment states it rather than
63
+ * hiding it. This is also the check M2-P7 step 8 reserves for the M4-era
64
+ * per-invocation extension ("the extension may never include a
65
+ * pull-request-capable credential").
66
+ *
67
+ * THE ENV-INJECTION VECTOR IS BEHAVIORALLY PROBED, NOT JUST NAME-CHECKED.
68
+ * git resolves a credential.helper injected via the GIT_CONFIG_COUNT /
69
+ * GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n family (git-config(1)) at a scope
70
+ * the --global and --system probes cannot see. So beyond the name
71
+ * tripwire, source `git-resolved-config` asks git itself, with NO scope
72
+ * flag, what credential.helper resolves from inside the child (env, then
73
+ * global, then system, then any repo-local config of the child's working
74
+ * directory). A resolvable helper from ANY source, including env
75
+ * injection, reddens the gate.
76
+ *
77
+ * `credential-token` (conditional). When TIPHYS_IMPLEMENTER_TOKEN is
78
+ * absent it reports `not-applicable` NAMING OWNER ACTION A-3, never green.
79
+ * When the token is present it currently reports `error`, deliberately:
80
+ * plan step 7 requires the probe's assertion to be DERIVED from captured
81
+ * API responses for a real scoped implementer token and a real
82
+ * orchestrator token, both of which owner action A-3 (DR-0004 item 4) has
83
+ * not yet provisioned. Deriving the assertion from invented responses is
84
+ * exactly the mechanism T-003 lesson 4 forbids, so until the captures
85
+ * exist the gate FAILS CLOSED (M2-C-3: a check that cannot reach a
86
+ * verdict is `error`). The live witness is deferred to the M2 exit test
87
+ * (plan criterion 7, owner-blocked).
88
+ */
89
+ const EX_USAGE = 64; // BSD sysexits, same value src/cli.ts exports.
90
+ /**
91
+ * THIS VOCABULARY IS NOW READ BY THE KERNEL AS WELL AS BY THIS GATE
92
+ * (M4-P8 step 4, and this comment IS the record that step requires).
93
+ *
94
+ * `src/exec/env.ts` imports `GH_TOKEN_VARIABLES` and `isDangerousEnvName`
95
+ * from here to refuse a per-invocation allowlist extension naming one of
96
+ * them. The plan offered two shapes, move the vocabulary to a third module
97
+ * or export it from here, and the choice taken is EXPORT FROM HERE: the
98
+ * walk above, the per-name sources below and the constants stay in one
99
+ * file, so a future editor extending the vocabulary cannot extend it
100
+ * somewhere the other reader does not see. What is forbidden is a second
101
+ * copy: two lists drift, and they drift silently toward the permissive
102
+ * side.
103
+ *
104
+ * NOTHING ABOUT WHAT EITHER GATE DECIDES CHANGES WITH THAT IMPORT. The
105
+ * constants, the pattern and `isDangerousEnvName` are byte-identical to
106
+ * their M2-P8 form; the only difference is that a second module now reads
107
+ * them.
108
+ */
109
+ /**
110
+ * gh's documented token vocabulary (see the module comment for the walk).
111
+ * Never permitted in a child environment, allowlisted or not.
112
+ */
113
+ export const GH_TOKEN_VARIABLES = [
114
+ "GH_TOKEN",
115
+ "GITHUB_TOKEN",
116
+ "GH_ENTERPRISE_TOKEN",
117
+ "GITHUB_ENTERPRISE_TOKEN",
118
+ ];
119
+ /**
120
+ * The credential- or code-execution-capable environment vocabulary of the
121
+ * programs a scrubbed child runs, walked from each program's own
122
+ * documentation. A child environment must contain NONE of these,
123
+ * allowlisted or not; the environment probe reddens if any is present.
124
+ * This is a BOUNDED denylist (it cannot list every dangerous name any
125
+ * program will ever read); the allowlist in src/exec/env.ts remains the
126
+ * real defense. Per-name source:
127
+ *
128
+ * git (git-config(1), git(1)):
129
+ * GIT_ASKPASS - program git runs to obtain a password.
130
+ * GIT_SSH_COMMAND - shell command git uses for its ssh transport.
131
+ * GIT_PROXY_COMMAND - external program for git:// connections.
132
+ * GIT_CONFIG_COUNT - trigger of the environment config-injection
133
+ * family; with GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
134
+ * it injects arbitrary config (e.g. a
135
+ * credential.helper) at a scope --global/--system
136
+ * cannot see. The numbered KEY_n / VALUE_n members
137
+ * are matched by isDangerousEnvName's pattern.
138
+ * ssh (ssh(1), ssh-add(1)):
139
+ * SSH_ASKPASS - program ssh runs to obtain a passphrase.
140
+ * SSH_AUTH_SOCK - the agent socket. ADDED BY THE DR-0047 SWEEP FIX
141
+ * ROUND, and the basis is stated here rather than
142
+ * implied because HALF OF IT IS UNVERIFIED. What was
143
+ * MEASURED in this container: the name sat in neither
144
+ * walked vocabulary, so the audited route ACCEPTED it
145
+ * (clean-room-final-credential-criteria CR-F-CRED-004),
146
+ * and buildChildEnv's default child does not carry it,
147
+ * so refusing an extension that names it withdraws
148
+ * nothing the default grants. What could NOT be
149
+ * verified here: this container has no ssh binary and
150
+ * no man page (`ssh -V` -> command not found, `man 1
151
+ * ssh` -> nothing), so the claim that ssh(1)'s own
152
+ * ENVIRONMENT section documents it was NOT read from
153
+ * the page. The row therefore rests on capability
154
+ * reasoning, not on the walk: the socket is a SIGNING
155
+ * channel (any holder can authenticate as the owner to
156
+ * any host the agent holds a key for), which is
157
+ * strictly stronger than SSH_ASKPASS, a passphrase
158
+ * PROMPT, which the same row already refuses. A later
159
+ * round with the page should either confirm this row
160
+ * from ssh(1) or move it and say why.
161
+ * node / dynamic loader / shell startup (node(1), ld.so(8), bash(1)):
162
+ * NODE_OPTIONS - options node applies at startup (can require
163
+ * arbitrary modules), arbitrary code execution.
164
+ * NODE_EXTRA_CA_CERTS- extra CAs node trusts, a TLS-trust channel.
165
+ * LD_PRELOAD - shared objects the loader injects into every
166
+ * dynamically linked program, arbitrary code.
167
+ * BASH_ENV - script bash sources at non-interactive startup.
168
+ * ENV - script the POSIX shell sources at startup.
169
+ *
170
+ * GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM are DELIBERATELY absent: they are
171
+ * redirected (CREDENTIAL_STORE_REDIRECTIONS) to empty harness-owned targets
172
+ * and the override runs last in buildChildEnv, so their mere presence is
173
+ * expected and neutralized; flagging them would false-positive on every
174
+ * real run.
175
+ */
176
+ export const DANGEROUS_ENV_VOCABULARY = [
177
+ "GIT_ASKPASS",
178
+ "GIT_SSH_COMMAND",
179
+ "GIT_PROXY_COMMAND",
180
+ "GIT_CONFIG_COUNT",
181
+ "SSH_ASKPASS",
182
+ "SSH_AUTH_SOCK",
183
+ "NODE_OPTIONS",
184
+ "NODE_EXTRA_CA_CERTS",
185
+ "LD_PRELOAD",
186
+ "BASH_ENV",
187
+ "ENV",
188
+ ];
189
+ /**
190
+ * THE EGRESS VOCABULARY (M4-P29, and this comment is the record of why it
191
+ * exists as a SEPARATE list rather than as more rows of the one above).
192
+ *
193
+ * WHAT WAS MEASURED. delivery/verification/m4-prototype-probes.md:46
194
+ * recorded that this gate's verdict is INVERTED with respect to real
195
+ * capability: it reddens `GIT_CONFIG_*`, which in that container bought
196
+ * only URL rewriting, and it greens `HTTPS_PROXY`, which bought full
197
+ * GitHub reach. M4-P8 then measured the same thing from the other side,
198
+ * four arms through `spawnTask` differing only in the allowlist extension
199
+ * (delivery/work-history/m4-p8.md:116): arm A, ten variables, HTTP 403
200
+ * from `api.github.com/user`; arm B, the same ten plus `HTTPS_PROXY`,
201
+ * HTTP 200. One variable, and it is the one nothing in this module knew
202
+ * about. A gate that cannot go red for the case that matters is the shape
203
+ * this repository keeps paying for.
204
+ *
205
+ * WHAT THIS FIXES AND WHAT IT DOES NOT, stated here rather than left to be
206
+ * discovered. It closes the case where the DEFAULT allowlist in
207
+ * src/exec/env.ts gains an egress name: this tripwire is
208
+ * allowlist-INDEPENDENT, exactly like the two above it, so the widening
209
+ * costs a red instead of buying a green. It does NOT close the case
210
+ * M4-P8's arm B actually used, which is a PER-INVOCATION
211
+ * `extraAllowlist`: that argument is runtime data of one spawn, this gate
212
+ * probes the constructed default child, and no probe of a default can see
213
+ * an argument a caller has not passed yet. Closing that half means
214
+ * auditing the extension record a task writes, which is a different gate
215
+ * and a different phase.
216
+ *
217
+ * WHY A SEPARATE LIST, AND WHAT DR-0048 CHANGED ABOUT ITS CONSEQUENCE.
218
+ * M4-P29 kept these names out of `DANGEROUS_ENV_VOCABULARY` so that a data
219
+ * edit in this file would not, as a side effect, refuse M4-P8's audited
220
+ * extension in a module that phase did not touch. It named the underlying
221
+ * question (whether an egress name may ever be extended) as a kernel design
222
+ * question with an owner-facing cost, and routed it onward rather than
223
+ * taking it by accident.
224
+ *
225
+ * DR-0048 answered that question: the audited route REFUSES egress names.
226
+ * The two lists still stay separate, because they are walked from different
227
+ * programs' documentation and their per-name evidence is different, and
228
+ * `isDangerousEnvName` is still byte-for-byte what it was. What changed is
229
+ * that the refusal in src/exec/env.ts no longer walks a HAND-PICKED PAIR of
230
+ * vocabularies: it walks `REFUSED_CHILD_ENV_VOCABULARIES` below, which is
231
+ * the declared list of every vocabulary in this module, this one included.
232
+ *
233
+ * THE WALK, per name, from the consuming programs' own documentation
234
+ * (curl(1) "ENVIRONMENT", git(1) "http_proxy", wget(1) "ENVIRONMENT"):
235
+ * each of these names a proxy a child's HTTP client will route through,
236
+ * in both the upper-case and lower-case spellings those pages document.
237
+ *
238
+ * HTTP_PROXY / http_proxy - proxy for http:// requests.
239
+ * HTTPS_PROXY / https_proxy - proxy for https:// requests. THE MEASURED
240
+ * NAME: arm A 403 against arm B 200.
241
+ * ALL_PROXY / all_proxy - proxy for every scheme.
242
+ * FTP_PROXY / ftp_proxy - proxy for ftp:// requests.
243
+ *
244
+ * TWO NAMES ARE DELIBERATELY ABSENT, and both absences are measured rather
245
+ * than assumed. `NO_PROXY` / `no_proxy` NARROW reach instead of granting
246
+ * it, so listing them would redden a child that is strictly less capable.
247
+ * `CURL_CA_BUNDLE` is a TLS-trust channel and not an egress grant: M4-P8's
248
+ * arm C added it on top of arm B and measured the same HTTP 200 arm B
249
+ * already had (delivery/work-history/m4-p8.md:117), so it buys no reach,
250
+ * and its node-side sibling `NODE_EXTRA_CA_CERTS` is already covered by
251
+ * `DANGEROUS_ENV_VOCABULARY` above.
252
+ *
253
+ * LIKE THE LIST ABOVE THIS IS A BOUNDED DENYLIST. The allowlist in
254
+ * src/exec/env.ts is still the real defense; this makes a widening cost a
255
+ * red for the names walked here, it does not enumerate every way a child
256
+ * could be handed network reach.
257
+ */
258
+ export const EGRESS_ENV_VOCABULARY = [
259
+ "HTTP_PROXY",
260
+ "http_proxy",
261
+ "HTTPS_PROXY",
262
+ "https_proxy",
263
+ "ALL_PROXY",
264
+ "all_proxy",
265
+ "FTP_PROXY",
266
+ "ftp_proxy",
267
+ ];
268
+ /** Whether a variable name is in the walked egress vocabulary. */
269
+ export function isEgressEnvName(name) {
270
+ return EGRESS_ENV_VOCABULARY.includes(name);
271
+ }
272
+ /**
273
+ * git-config(1)'s numbered environment config-injection members:
274
+ * GIT_CONFIG_KEY_<n> and GIT_CONFIG_VALUE_<n> for n in [0, COUNT). The
275
+ * index is git's own documented closed shape (a non-negative integer), so
276
+ * this is a vocabulary match, not a widened guess.
277
+ */
278
+ const GIT_CONFIG_INJECTION_MEMBER = /^GIT_CONFIG_(KEY|VALUE)_\d+$/;
279
+ /** Whether a variable name is in the walked dangerous vocabulary. */
280
+ export function isDangerousEnvName(name) {
281
+ return (DANGEROUS_ENV_VOCABULARY.includes(name) ||
282
+ GIT_CONFIG_INJECTION_MEMBER.test(name));
283
+ }
284
+ /**
285
+ * EVERY VOCABULARY A CHILD-ENVIRONMENT REFUSAL MUST WALK, IN ONE PLACE.
286
+ *
287
+ * THE MECHANISM THIS EXISTS AGAINST (CH-001 high, CR-F-CRED-003, CR-F-CRED-004;
288
+ * DR-0048): a refusal that walks ONE vocabulary, or a hand-picked subset of
289
+ * them, while several exist. `refuseExtraAllowlist` (src/exec/env.ts) named
290
+ * `GH_TOKEN_VARIABLES` and `isDangerousEnvName` as two literal `if` arms and
291
+ * therefore could not see `EGRESS_ENV_VOCABULARY`, which this module had held
292
+ * since M4-P29. The defect is not that the third name was forgotten once; it
293
+ * is that ADDING a vocabulary to this module left every consumer's coverage
294
+ * unchanged and silent, so the subset could only be discovered by probing a
295
+ * name.
296
+ *
297
+ * So the list of vocabularies is data, exported from the module that owns
298
+ * them, and a consumer walks the LIST rather than naming members of it. A
299
+ * fourth vocabulary added below is walked by every consumer of this array the
300
+ * moment it gains a row here, and `test/payload-credentials.test.ts` reddens
301
+ * if a `*_VOCABULARY` or `*_VARIABLES` export of this module has NO row,
302
+ * which is the case a reader cannot see by reading either file alone.
303
+ *
304
+ * `probeCredentialSources` deliberately does NOT walk this array: it reports a
305
+ * different sentence per vocabulary in one fixed order, strongest first, and the three
306
+ * sentences are read by operators. It is covered by the same drift test from
307
+ * the other side.
308
+ */
309
+ export const REFUSED_CHILD_ENV_VOCABULARIES = [
310
+ {
311
+ id: "gh-token",
312
+ constantName: "GH_TOKEN_VARIABLES",
313
+ includes: (name) => GH_TOKEN_VARIABLES.includes(name),
314
+ clause: "is a documented gh token variable and may never cross into a child " +
315
+ "environment; the default allowlist gains no credential name and " +
316
+ "neither may an extension",
317
+ },
318
+ {
319
+ id: "dangerous",
320
+ constantName: "DANGEROUS_ENV_VOCABULARY",
321
+ includes: isDangerousEnvName,
322
+ clause: "is in the walked credential- or code-execution-capable vocabulary " +
323
+ "(src/gates/credentials.ts) and may never cross into a child environment",
324
+ },
325
+ {
326
+ id: "egress",
327
+ constantName: "EGRESS_ENV_VOCABULARY",
328
+ includes: isEgressEnvName,
329
+ clause: "is in the walked network-egress vocabulary (src/gates/credentials.ts) " +
330
+ "and may never cross into a child environment; DR-0048 decided that " +
331
+ "egress is granted through a declared route with a reader, never " +
332
+ "through an allowlist extension whose only audit is a prose reason",
333
+ },
334
+ ];
335
+ /**
336
+ * The first vocabulary claiming `name`, or undefined. The ORDER of
337
+ * `REFUSED_CHILD_ENV_VOCABULARIES` is the order the refusal reports, and it
338
+ * is narrowest-first so that a name in two vocabularies is reported as the
339
+ * more specific one.
340
+ */
341
+ export function refusedEnvVocabulary(name) {
342
+ return REFUSED_CHILD_ENV_VOCABULARIES.find((vocabulary) => vocabulary.includes(name));
343
+ }
344
+ /** The names credential-scrub probes, in probe order. */
345
+ export const CREDENTIAL_SOURCES = [
346
+ "environment",
347
+ "gh-configuration",
348
+ "git-global-config",
349
+ "git-system-config",
350
+ "git-resolved-config",
351
+ "netrc",
352
+ "git-credentials",
353
+ ];
354
+ function probe(source, outcome, detail) {
355
+ return { source, outcome, detail };
356
+ }
357
+ /** A regular file with content at a credential-store path. */
358
+ function fileProbe(source, paths) {
359
+ const found = [];
360
+ for (const path of paths) {
361
+ // M2-C-6: the type is established before the open, and a path that is
362
+ // present but not a readable regular file is `error`, never guessed
363
+ // clean and never blocked on.
364
+ const read = readRegularFileIfPresent(path);
365
+ if (read.kind === "refused") {
366
+ return probe(source, "error", read.reason);
367
+ }
368
+ if (read.kind === "read" && read.body.length > 0) {
369
+ found.push(`${path} (${String(read.body.length)} bytes)`);
370
+ }
371
+ }
372
+ if (found.length > 0) {
373
+ return probe(source, "resolvable", `credential store reachable from inside the child environment: ${found.join(", ")}`);
374
+ }
375
+ return probe(source, "clean", `no populated store at ${paths.join(", ")}`);
376
+ }
377
+ /** Locate an executable on a PATH string, or undefined. */
378
+ function findOnPath(pathValue, program) {
379
+ if (pathValue === undefined) {
380
+ return undefined;
381
+ }
382
+ for (const dir of pathValue.split(delimiter)) {
383
+ if (dir === "") {
384
+ continue;
385
+ }
386
+ const candidate = join(dir, program);
387
+ try {
388
+ accessSync(candidate, constants.X_OK);
389
+ return candidate;
390
+ }
391
+ catch {
392
+ // Not here; keep walking.
393
+ }
394
+ }
395
+ return undefined;
396
+ }
397
+ /**
398
+ * Probe every credential source FROM INSIDE the given child environment.
399
+ * The environment is the one under test: every subprocess probe below runs
400
+ * with `env` as its entire environment, so what is asserted is what a
401
+ * child launched with this environment could actually resolve.
402
+ */
403
+ export function probeCredentialSources(env, options = {}) {
404
+ const permitted = options.permittedNames ?? permittedChildEnvNames();
405
+ const probes = [];
406
+ // Source 1: the environment itself. Three checks. The first two are
407
+ // allowlist-INDEPENDENT tripwires (they fire even on a permitted name):
408
+ // no gh-documented token variable, and no git/ssh/node credential- or
409
+ // code-execution-capable variable from the walked vocabulary. The third
410
+ // is the allowlist-dependent stray check (anything outside the
411
+ // constructed contract), which in a real run is tautological because the
412
+ // child is built from that contract, and does real work only in tests
413
+ // that hand-build an env with extra names.
414
+ const names = Object.keys(env).filter((name) => env[name] !== undefined);
415
+ const tokens = names.filter((name) => GH_TOKEN_VARIABLES.includes(name));
416
+ const dangerous = names.filter((name) => isDangerousEnvName(name));
417
+ const egress = names.filter((name) => isEgressEnvName(name));
418
+ const strays = names.filter((name) => !permitted.has(name));
419
+ if (tokens.length > 0) {
420
+ probes.push(probe("environment", "resolvable", `pull-request-capable token variable(s) present in the child environment: ${tokens.join(", ")}`));
421
+ }
422
+ else if (dangerous.length > 0) {
423
+ probes.push(probe("environment", "resolvable", `credential- or code-execution-capable variable(s) from the walked vocabulary present in the child environment: ${dangerous.join(", ")}`));
424
+ }
425
+ else if (egress.length > 0) {
426
+ // ALLOWLIST-INDEPENDENT, and that placement is the whole point: it sits
427
+ // ABOVE the stray check so it still fires on a name the allowlist has
428
+ // been widened to permit. Below it, a widened allowlist would make the
429
+ // name non-stray and this probe would report clean, which is the green
430
+ // M4-P8's arm B measured while the child had HTTP 200 to the GitHub API.
431
+ probes.push(probe("environment", "resolvable", `network-egress variable(s) from the walked proxy vocabulary present in the child environment: ${egress.join(", ")}`));
432
+ }
433
+ else if (strays.length > 0) {
434
+ probes.push(probe("environment", "resolvable", `variable(s) outside the constructed contract present in the child environment: ${strays.join(", ")}`));
435
+ }
436
+ else {
437
+ probes.push(probe("environment", "clean", `${String(names.length)} variable(s), all inside the constructed contract, no gh token, no walked-vocabulary variable and no walked proxy variable`));
438
+ }
439
+ // Source 2: gh configuration. The store is hosts.yml at gh's documented
440
+ // resolution order (GH_CONFIG_DIR, else XDG_CONFIG_HOME/gh, else
441
+ // HOME/.config/gh); all three are probed rather than only the first so
442
+ // a partial redirection cannot hide a reachable store. Where a gh
443
+ // binary is resolvable on the child PATH, gh itself is also asked: exit
444
+ // 0 from `gh auth status` means some host authenticated, which is a
445
+ // resolvable credential regardless of which file it came from.
446
+ const ghStorePaths = [];
447
+ if (env["GH_CONFIG_DIR"] !== undefined) {
448
+ ghStorePaths.push(join(env["GH_CONFIG_DIR"], "hosts.yml"));
449
+ }
450
+ if (env["XDG_CONFIG_HOME"] !== undefined) {
451
+ ghStorePaths.push(join(env["XDG_CONFIG_HOME"], "gh", "hosts.yml"));
452
+ }
453
+ if (env["HOME"] !== undefined) {
454
+ ghStorePaths.push(join(env["HOME"], ".config", "gh", "hosts.yml"));
455
+ }
456
+ let ghProbe = fileProbe("gh-configuration", ghStorePaths);
457
+ if (ghProbe.outcome === "clean") {
458
+ const gh = findOnPath(env["PATH"], "gh");
459
+ if (gh === undefined) {
460
+ ghProbe = probe("gh-configuration", "clean", `${ghProbe.detail}; no gh binary on the child PATH, so no CLI resolution path exists either`);
461
+ }
462
+ else {
463
+ const status = spawnSync(gh, ["auth", "status"], {
464
+ env: env,
465
+ encoding: "utf8",
466
+ timeout: 15000,
467
+ });
468
+ if (status.error !== undefined) {
469
+ ghProbe = probe("gh-configuration", "error", `gh auth status could not be run: ${singleLine(String(status.error))}`);
470
+ }
471
+ else if (status.status === 0) {
472
+ ghProbe = probe("gh-configuration", "resolvable", "gh auth status exited 0 from inside the child environment: some host is authenticated");
473
+ }
474
+ else {
475
+ ghProbe = probe("gh-configuration", "clean", `${ghProbe.detail}; gh auth status exited ${String(status.status)} (no authenticated host)`);
476
+ }
477
+ }
478
+ }
479
+ probes.push(ghProbe);
480
+ // Sources 3 and 4: git global and system configuration, asked through
481
+ // git itself (`git config --get-all credential.helper`), because git is
482
+ // the program that would resolve a helper and its exit code is the
483
+ // documented contract: 0 with output means the key is set, 1 means it
484
+ // is not. Nothing here parses message text (T-003).
485
+ for (const scope of ["global", "system"]) {
486
+ const result = spawnSync("git", ["config", `--${scope}`, "--get-all", "credential.helper"], {
487
+ env: env,
488
+ encoding: "utf8",
489
+ timeout: 15000,
490
+ });
491
+ const source = `git-${scope}-config`;
492
+ if (result.error !== undefined) {
493
+ probes.push(probe(source, "error", `git config --${scope} could not be run: ${singleLine(String(result.error))}`));
494
+ }
495
+ else if (result.status === 0 && (result.stdout ?? "").trim() !== "") {
496
+ probes.push(probe(source, "resolvable", `git config --${scope} --get-all credential.helper resolves: ${singleLine((result.stdout ?? "").trim())}`));
497
+ }
498
+ else {
499
+ probes.push(probe(source, "clean", `git config --${scope} --get-all credential.helper exited ${String(result.status)} with no output`));
500
+ }
501
+ }
502
+ // Source 5 (behavioral): what git ACTUALLY resolves for credential.helper
503
+ // from inside the child, with NO scope flag. This catches the env-injection
504
+ // vector the two scoped probes structurally miss: a credential.helper
505
+ // injected via git's GIT_CONFIG_COUNT / GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n
506
+ // family resolves here (exit 0 with output) while --global and --system see
507
+ // nothing. It resolves env, then global, then system, then any repo-local
508
+ // config of the working directory. The probe runs from the child's
509
+ // redirected HOME (an empty, non-repo directory) rather than the gate's own
510
+ // cwd, so it does not pick up repo-local config of whatever tree the gate
511
+ // happens to run in; repo-local config of an arbitrary worktree is out of
512
+ // the environment scrub's scope (the gate has no worktree). Nothing here
513
+ // parses message text (T-003): the contract is git's exit code.
514
+ const resolvedCwd = env["HOME"] !== undefined && existsSync(env["HOME"]) ? env["HOME"] : undefined;
515
+ const resolved = spawnSync("git", ["config", "--get-all", "credential.helper"], {
516
+ env: env,
517
+ encoding: "utf8",
518
+ timeout: 15000,
519
+ cwd: resolvedCwd,
520
+ });
521
+ if (resolved.error !== undefined) {
522
+ probes.push(probe("git-resolved-config", "error", `git config --get-all could not be run: ${singleLine(String(resolved.error))}`));
523
+ }
524
+ else if (resolved.status === 0 && (resolved.stdout ?? "").trim() !== "") {
525
+ probes.push(probe("git-resolved-config", "resolvable", `git config --get-all credential.helper resolves from inside the child (any source, including env injection): ${singleLine((resolved.stdout ?? "").trim())}`));
526
+ }
527
+ else {
528
+ probes.push(probe("git-resolved-config", "clean", `git config --get-all credential.helper exited ${String(resolved.status)} with no output`));
529
+ }
530
+ // Source 6: ~/.netrc, resolved from the child HOME.
531
+ const netrcPaths = env["HOME"] === undefined ? [] : [join(env["HOME"], ".netrc")];
532
+ if (netrcPaths.length === 0) {
533
+ probes.push(probe("netrc", "error", "HOME is not set in the child environment, so the netrc resolution path cannot be established"));
534
+ }
535
+ else {
536
+ probes.push(fileProbe("netrc", netrcPaths));
537
+ }
538
+ // Source 7: git's store-backed credential files, both documented
539
+ // locations (~/.git-credentials and $XDG_CONFIG_HOME/git/credentials).
540
+ const credPaths = [];
541
+ if (env["HOME"] !== undefined) {
542
+ credPaths.push(join(env["HOME"], ".git-credentials"));
543
+ }
544
+ if (env["XDG_CONFIG_HOME"] !== undefined) {
545
+ credPaths.push(join(env["XDG_CONFIG_HOME"], "git", "credentials"));
546
+ }
547
+ if (credPaths.length === 0) {
548
+ probes.push(probe("git-credentials", "error", "neither HOME nor XDG_CONFIG_HOME is set in the child environment, so no store path can be established"));
549
+ }
550
+ else {
551
+ probes.push(fileProbe("git-credentials", credPaths));
552
+ }
553
+ return probes;
554
+ }
555
+ /** Fold probes into a gate verdict. Any error wins over any red. */
556
+ export function verdictFromProbes(probes) {
557
+ const errors = probes.filter((entry) => entry.outcome === "error");
558
+ if (errors.length > 0) {
559
+ return {
560
+ status: "error",
561
+ detail: errors.map((entry) => `${entry.source}: ${entry.detail}`).join("; "),
562
+ };
563
+ }
564
+ const resolvable = probes.filter((entry) => entry.outcome === "resolvable");
565
+ if (resolvable.length > 0) {
566
+ return {
567
+ status: "red",
568
+ detail: "credential resolvable from inside the scrubbed child environment: " +
569
+ resolvable.map((entry) => `${entry.source}: ${entry.detail}`).join("; "),
570
+ };
571
+ }
572
+ return {
573
+ status: "green",
574
+ detail: `no pull-request-capable credential resolvable from any of the ${String(probes.length)} probed sources`,
575
+ };
576
+ }
577
+ function parseGateArgs(argv) {
578
+ const [gateId, ...rest] = argv;
579
+ if (gateId !== "credential-scrub" && gateId !== "credential-token") {
580
+ return "expected a gate id: credential-scrub or credential-token";
581
+ }
582
+ let resultPath;
583
+ let evidenceDir;
584
+ for (let i = 0; i < rest.length; i += 1) {
585
+ const flag = rest[i];
586
+ const value = rest[i + 1];
587
+ if (flag === "--result" && value !== undefined) {
588
+ resultPath = value;
589
+ i += 1;
590
+ }
591
+ else if (flag === "--evidence" && value !== undefined) {
592
+ evidenceDir = value;
593
+ i += 1;
594
+ }
595
+ else {
596
+ return `unknown argument ${String(flag)}`;
597
+ }
598
+ }
599
+ if (resultPath === undefined || evidenceDir === undefined) {
600
+ return "both --result <path> and --evidence <dir> are required";
601
+ }
602
+ return {
603
+ gateId,
604
+ resultPath: isAbsolute(resultPath) ? resultPath : resolve(resultPath),
605
+ evidenceDir: isAbsolute(evidenceDir) ? evidenceDir : resolve(evidenceDir),
606
+ };
607
+ }
608
+ function runCredentialScrub(evidenceDir, startedAt) {
609
+ // The gate's harness-owned scrub root lives inside its own evidence
610
+ // directory: the gate probes the CONSTRUCTION, and the construction is
611
+ // the same buildChildEnv the executor calls, so what is green here is
612
+ // the same mechanism spawn hands its children.
613
+ const built = buildChildEnv({
614
+ parentEnv: process.env,
615
+ scrubDir: join(evidenceDir, "scrub-env"),
616
+ });
617
+ if (!built.ok) {
618
+ return {
619
+ result: makeGateResult({
620
+ gate: "credential-scrub",
621
+ status: "error",
622
+ units: 0,
623
+ unitLabel: "credential sources probed",
624
+ startedAt,
625
+ endedAt: new Date().toISOString(),
626
+ detail: `the child environment could not be constructed: ${built.reason}`,
627
+ }),
628
+ };
629
+ }
630
+ const probes = probeCredentialSources(built.env);
631
+ const verdict = verdictFromProbes(probes);
632
+ const units = verdict.status === "error" ? 0 : probes.length;
633
+ return {
634
+ result: makeGateResult({
635
+ gate: "credential-scrub",
636
+ status: verdict.status,
637
+ units,
638
+ unitLabel: "credential sources probed",
639
+ startedAt,
640
+ endedAt: new Date().toISOString(),
641
+ detail: verdict.detail,
642
+ evidence: ["probes.json"],
643
+ }),
644
+ evidenceBody: `${JSON.stringify(probes, null, 2)}\n`,
645
+ };
646
+ }
647
+ function runCredentialToken(startedAt) {
648
+ if (process.env["TIPHYS_IMPLEMENTER_TOKEN"] === undefined) {
649
+ return {
650
+ result: makeGateResult({
651
+ gate: "credential-token",
652
+ status: "not-applicable",
653
+ units: 0,
654
+ unitLabel: "tokens probed",
655
+ startedAt,
656
+ endedAt: new Date().toISOString(),
657
+ detail: "TIPHYS_IMPLEMENTER_TOKEN is not present: owner action A-3 " +
658
+ "(DR-0004 item 4, the scoped implementer token) has not been " +
659
+ "performed, so there is no token to probe. This gate never " +
660
+ "reports green in this state.",
661
+ }),
662
+ };
663
+ }
664
+ // Fail closed (M2-C-3). See the module comment: the safe negative
665
+ // probe's assertion must be derived from captured API responses (plan
666
+ // M2-P8 step 7), and those captures require the very tokens A-3
667
+ // provisions. Guessing a response shape here is T-003 lesson 4.
668
+ return {
669
+ result: makeGateResult({
670
+ gate: "credential-token",
671
+ status: "error",
672
+ units: 0,
673
+ unitLabel: "tokens probed",
674
+ startedAt,
675
+ endedAt: new Date().toISOString(),
676
+ detail: "TIPHYS_IMPLEMENTER_TOKEN is present, but the probe's assertion " +
677
+ "contract has not yet been derived from captured API responses " +
678
+ "(kernel plan M2, M2-P8 step 7; owner action A-3). Refusing to " +
679
+ "assert against an invented response shape (T-003 lesson 4); " +
680
+ "this gate fails closed until the captures exist and is " +
681
+ "witnessed live at the M2 exit test.",
682
+ }),
683
+ };
684
+ }
685
+ function gateMain(argv) {
686
+ const parsed = parseGateArgs(argv);
687
+ if (typeof parsed === "string") {
688
+ process.stderr.write(`credentials gate: ${parsed}\n` +
689
+ "usage: node src/gates/credentials.ts credential-scrub|credential-token " +
690
+ "--result <path> --evidence <dir>\n");
691
+ return EX_USAGE;
692
+ }
693
+ const startedAt = new Date().toISOString();
694
+ const outcome = parsed.gateId === "credential-scrub"
695
+ ? runCredentialScrub(parsed.evidenceDir, startedAt)
696
+ : runCredentialToken(startedAt);
697
+ if (outcome.evidenceBody !== undefined) {
698
+ const evidencePath = join(parsed.evidenceDir, "probes.json");
699
+ const refusal = refuseOpenForWrite(evidencePath);
700
+ if (refusal !== undefined) {
701
+ process.stderr.write(`credentials gate: ${refusal}\n`);
702
+ return exitCodeForStatus("error");
703
+ }
704
+ const wrote = runStep(`writing ${evidencePath}`, () => {
705
+ writeFileSync(evidencePath, outcome.evidenceBody);
706
+ });
707
+ if (!wrote.ok) {
708
+ process.stderr.write(`credentials gate: ${wrote.reason}\n`);
709
+ return exitCodeForStatus("error");
710
+ }
711
+ }
712
+ const recordRefusal = refuseOpenForWrite(parsed.resultPath);
713
+ if (recordRefusal !== undefined) {
714
+ process.stderr.write(`credentials gate: ${recordRefusal}\n`);
715
+ return exitCodeForStatus("error");
716
+ }
717
+ const written = runStep(`writing ${parsed.resultPath}`, () => {
718
+ writeFileSync(parsed.resultPath, renderGateResult(outcome.result));
719
+ });
720
+ if (!written.ok) {
721
+ process.stderr.write(`credentials gate: ${written.reason}\n`);
722
+ return exitCodeForStatus("error");
723
+ }
724
+ return exitCodeForStatus(outcome.result.status);
725
+ }
726
+ // Main guard: run as a gate subprocess when executed directly, inert on
727
+ // import (tests import the probe functions without running a gate).
728
+ const entry = process.argv[1];
729
+ if (entry !== undefined) {
730
+ const isMain = pathsIdentifySameObject(fileURLToPath(import.meta.url), entry);
731
+ if (isMain) {
732
+ // `process.exitCode`, NEVER `process.exit(gateMain(...))` (M4-P29). This
733
+ // gate runs as a SUBPROCESS, so its output goes to a buffered stream the
734
+ // parent owns and a write to one is QUEUED rather than completed.
735
+ // `process.exit` ends the process without draining that queue, so
736
+ // everything past the buffer is DISCARDED, while the exit code survives
737
+ // and the loss is silent.
738
+ //
739
+ // THE MEASURED INSTANCE HERE IS STDERR, which the plan section states in
740
+ // as many words it did not examine: this module writes nothing to
741
+ // stdout. A 130,143-byte usage refusal from `gateMain` arrived as 65,536
742
+ // bytes through `| cat` at the pre-fix parent commit, one pipe buffer
743
+ // exactly, and arrives whole after this change. It arrives whole through
744
+ // a file redirection either way, which is why the defect survives casual
745
+ // testing. Assigning `process.exitCode` lets the process end normally,
746
+ // which drains the queue first. The same rule holds for stdout and is
747
+ // why src/gates/citations.ts, src/gates/scope.ts and
748
+ // src/gates/gate-classes.ts already read this way; the full capture is
749
+ // witness/captures/m4-p29-gate-cli-stdio.txt.
750
+ process.exitCode = gateMain(process.argv.slice(2));
751
+ }
752
+ }