@tiphys/kernel 0.1.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 (94) hide show
  1. package/AGENTS.md +56 -4
  2. package/assurance-modes.yaml +23 -2
  3. package/dist/bin/tiphys.js +86 -8
  4. package/dist/src/adapters/load.d.ts +202 -0
  5. package/dist/src/adapters/load.js +440 -0
  6. package/dist/src/brief.js +27 -20
  7. package/dist/src/checks.d.ts +720 -9
  8. package/dist/src/checks.js +1874 -163
  9. package/dist/src/cli.js +11 -0
  10. package/dist/src/commands/brief.js +27 -4
  11. package/dist/src/commands/cutover.d.ts +35 -0
  12. package/dist/src/commands/cutover.js +448 -0
  13. package/dist/src/commands/doctor.d.ts +229 -0
  14. package/dist/src/commands/doctor.js +968 -27
  15. package/dist/src/commands/init.d.ts +3 -3
  16. package/dist/src/commands/init.js +57 -8
  17. package/dist/src/commands/lock.d.ts +33 -0
  18. package/dist/src/commands/lock.js +117 -6
  19. package/dist/src/commands/next.d.ts +130 -0
  20. package/dist/src/commands/next.js +597 -0
  21. package/dist/src/commands/pool.js +12 -1
  22. package/dist/src/commands/resume.d.ts +1 -0
  23. package/dist/src/commands/resume.js +88 -0
  24. package/dist/src/commands/spawn.js +51 -2
  25. package/dist/src/commands/status.d.ts +6 -4
  26. package/dist/src/commands/status.js +6 -4
  27. package/dist/src/commands/sync.d.ts +47 -0
  28. package/dist/src/commands/sync.js +341 -0
  29. package/dist/src/commands/teardown.js +10 -2
  30. package/dist/src/commands/validate.js +70 -0
  31. package/dist/src/cutover.d.ts +584 -0
  32. package/dist/src/cutover.js +1444 -0
  33. package/dist/src/exclusion.d.ts +389 -0
  34. package/dist/src/exclusion.js +843 -0
  35. package/dist/src/exec/env.d.ts +152 -2
  36. package/dist/src/exec/env.js +146 -2
  37. package/dist/src/fleet.d.ts +172 -0
  38. package/dist/src/fleet.js +219 -1
  39. package/dist/src/gates/citations.js +7 -1
  40. package/dist/src/gates/coverage.d.ts +113 -22
  41. package/dist/src/gates/coverage.js +166 -31
  42. package/dist/src/gates/credentials.d.ts +159 -0
  43. package/dist/src/gates/credentials.js +221 -2
  44. package/dist/src/gates/gate-classes.d.ts +56 -0
  45. package/dist/src/gates/gate-classes.js +633 -0
  46. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  47. package/dist/src/gates/merge-preconditions.js +932 -0
  48. package/dist/src/gates/red-witness.js +105 -13
  49. package/dist/src/gates/run.d.ts +49 -1
  50. package/dist/src/gates/run.js +83 -5
  51. package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
  52. package/dist/src/gates/suite.js +48 -7
  53. package/dist/src/hooks.d.ts +55 -3
  54. package/dist/src/hooks.js +69 -6
  55. package/dist/src/index.d.ts +31 -0
  56. package/dist/src/index.js +30 -0
  57. package/dist/src/lock.d.ts +82 -4
  58. package/dist/src/lock.js +314 -22
  59. package/dist/src/model-resolution.d.ts +159 -0
  60. package/dist/src/model-resolution.js +307 -0
  61. package/dist/src/path-identity.d.ts +32 -0
  62. package/dist/src/path-identity.js +38 -0
  63. package/dist/src/pool.d.ts +197 -1
  64. package/dist/src/pool.js +289 -22
  65. package/dist/src/roles.d.ts +31 -0
  66. package/dist/src/roles.js +42 -0
  67. package/dist/src/spawn.d.ts +307 -2
  68. package/dist/src/spawn.js +690 -19
  69. package/dist/src/status.d.ts +27 -2
  70. package/dist/src/status.js +34 -5
  71. package/dist/src/task.d.ts +295 -55
  72. package/dist/src/task.js +125 -123
  73. package/dist/src/teardown.d.ts +7 -0
  74. package/dist/src/teardown.js +120 -12
  75. package/dist/src/validate.d.ts +44 -11
  76. package/dist/src/validate.js +44 -34
  77. package/dist/src/watcher.js +1 -11
  78. package/dist/src/witness/run.d.ts +32 -7
  79. package/dist/src/witness/run.js +76 -30
  80. package/dist/src/witness/spec.d.ts +168 -0
  81. package/dist/src/witness/spec.js +240 -18
  82. package/dist/tsconfig.src.tsbuildinfo +1 -1
  83. package/gate-registry.yaml +136 -0
  84. package/gates.manifest.json +63 -1
  85. package/package.json +18 -3
  86. package/roles/implementer.md +3 -0
  87. package/schemas/README.md +1 -0
  88. package/schemas/assurance-modes.schema.json +1 -1
  89. package/schemas/charter.schema.json +19 -0
  90. package/schemas/cutover-state.schema.json +64 -0
  91. package/schemas/executor-record.schema.json +36 -0
  92. package/schemas/model-resolution.schema.json +362 -0
  93. package/schemas/verdict.schema.json +9 -3
  94. package/schemas/write-bypass.schema.json +69 -0
@@ -6,6 +6,25 @@ export interface SourceProbe {
6
6
  outcome: "clean" | "resolvable" | "error";
7
7
  detail: string;
8
8
  }
9
+ /**
10
+ * THIS VOCABULARY IS NOW READ BY THE KERNEL AS WELL AS BY THIS GATE
11
+ * (M4-P8 step 4, and this comment IS the record that step requires).
12
+ *
13
+ * `src/exec/env.ts` imports `GH_TOKEN_VARIABLES` and `isDangerousEnvName`
14
+ * from here to refuse a per-invocation allowlist extension naming one of
15
+ * them. The plan offered two shapes, move the vocabulary to a third module
16
+ * or export it from here, and the choice taken is EXPORT FROM HERE: the
17
+ * walk above, the per-name sources below and the constants stay in one
18
+ * file, so a future editor extending the vocabulary cannot extend it
19
+ * somewhere the other reader does not see. What is forbidden is a second
20
+ * copy: two lists drift, and they drift silently toward the permissive
21
+ * side.
22
+ *
23
+ * NOTHING ABOUT WHAT EITHER GATE DECIDES CHANGES WITH THAT IMPORT. The
24
+ * constants, the pattern and `isDangerousEnvName` are byte-identical to
25
+ * their M2-P8 form; the only difference is that a second module now reads
26
+ * them.
27
+ */
9
28
  /**
10
29
  * gh's documented token vocabulary (see the module comment for the walk).
11
30
  * Never permitted in a child environment, allowlisted or not.
@@ -32,6 +51,27 @@ export declare const GH_TOKEN_VARIABLES: readonly string[];
32
51
  * are matched by isDangerousEnvName's pattern.
33
52
  * ssh (ssh(1), ssh-add(1)):
34
53
  * SSH_ASKPASS - program ssh runs to obtain a passphrase.
54
+ * SSH_AUTH_SOCK - the agent socket. ADDED BY THE DR-0047 SWEEP FIX
55
+ * ROUND, and the basis is stated here rather than
56
+ * implied because HALF OF IT IS UNVERIFIED. What was
57
+ * MEASURED in this container: the name sat in neither
58
+ * walked vocabulary, so the audited route ACCEPTED it
59
+ * (clean-room-final-credential-criteria CR-F-CRED-004),
60
+ * and buildChildEnv's default child does not carry it,
61
+ * so refusing an extension that names it withdraws
62
+ * nothing the default grants. What could NOT be
63
+ * verified here: this container has no ssh binary and
64
+ * no man page (`ssh -V` -> command not found, `man 1
65
+ * ssh` -> nothing), so the claim that ssh(1)'s own
66
+ * ENVIRONMENT section documents it was NOT read from
67
+ * the page. The row therefore rests on capability
68
+ * reasoning, not on the walk: the socket is a SIGNING
69
+ * channel (any holder can authenticate as the owner to
70
+ * any host the agent holds a key for), which is
71
+ * strictly stronger than SSH_ASKPASS, a passphrase
72
+ * PROMPT, which the same row already refuses. A later
73
+ * round with the page should either confirm this row
74
+ * from ssh(1) or move it and say why.
35
75
  * node / dynamic loader / shell startup (node(1), ld.so(8), bash(1)):
36
76
  * NODE_OPTIONS - options node applies at startup (can require
37
77
  * arbitrary modules), arbitrary code execution.
@@ -48,8 +88,127 @@ export declare const GH_TOKEN_VARIABLES: readonly string[];
48
88
  * real run.
49
89
  */
50
90
  export declare const DANGEROUS_ENV_VOCABULARY: readonly string[];
91
+ /**
92
+ * THE EGRESS VOCABULARY (M4-P29, and this comment is the record of why it
93
+ * exists as a SEPARATE list rather than as more rows of the one above).
94
+ *
95
+ * WHAT WAS MEASURED. delivery/verification/m4-prototype-probes.md:46
96
+ * recorded that this gate's verdict is INVERTED with respect to real
97
+ * capability: it reddens `GIT_CONFIG_*`, which in that container bought
98
+ * only URL rewriting, and it greens `HTTPS_PROXY`, which bought full
99
+ * GitHub reach. M4-P8 then measured the same thing from the other side,
100
+ * four arms through `spawnTask` differing only in the allowlist extension
101
+ * (delivery/work-history/m4-p8.md:116): arm A, ten variables, HTTP 403
102
+ * from `api.github.com/user`; arm B, the same ten plus `HTTPS_PROXY`,
103
+ * HTTP 200. One variable, and it is the one nothing in this module knew
104
+ * about. A gate that cannot go red for the case that matters is the shape
105
+ * this repository keeps paying for.
106
+ *
107
+ * WHAT THIS FIXES AND WHAT IT DOES NOT, stated here rather than left to be
108
+ * discovered. It closes the case where the DEFAULT allowlist in
109
+ * src/exec/env.ts gains an egress name: this tripwire is
110
+ * allowlist-INDEPENDENT, exactly like the two above it, so the widening
111
+ * costs a red instead of buying a green. It does NOT close the case
112
+ * M4-P8's arm B actually used, which is a PER-INVOCATION
113
+ * `extraAllowlist`: that argument is runtime data of one spawn, this gate
114
+ * probes the constructed default child, and no probe of a default can see
115
+ * an argument a caller has not passed yet. Closing that half means
116
+ * auditing the extension record a task writes, which is a different gate
117
+ * and a different phase.
118
+ *
119
+ * WHY A SEPARATE LIST, AND WHAT DR-0048 CHANGED ABOUT ITS CONSEQUENCE.
120
+ * M4-P29 kept these names out of `DANGEROUS_ENV_VOCABULARY` so that a data
121
+ * edit in this file would not, as a side effect, refuse M4-P8's audited
122
+ * extension in a module that phase did not touch. It named the underlying
123
+ * question (whether an egress name may ever be extended) as a kernel design
124
+ * question with an owner-facing cost, and routed it onward rather than
125
+ * taking it by accident.
126
+ *
127
+ * DR-0048 answered that question: the audited route REFUSES egress names.
128
+ * The two lists still stay separate, because they are walked from different
129
+ * programs' documentation and their per-name evidence is different, and
130
+ * `isDangerousEnvName` is still byte-for-byte what it was. What changed is
131
+ * that the refusal in src/exec/env.ts no longer walks a HAND-PICKED PAIR of
132
+ * vocabularies: it walks `REFUSED_CHILD_ENV_VOCABULARIES` below, which is
133
+ * the declared list of every vocabulary in this module, this one included.
134
+ *
135
+ * THE WALK, per name, from the consuming programs' own documentation
136
+ * (curl(1) "ENVIRONMENT", git(1) "http_proxy", wget(1) "ENVIRONMENT"):
137
+ * each of these names a proxy a child's HTTP client will route through,
138
+ * in both the upper-case and lower-case spellings those pages document.
139
+ *
140
+ * HTTP_PROXY / http_proxy - proxy for http:// requests.
141
+ * HTTPS_PROXY / https_proxy - proxy for https:// requests. THE MEASURED
142
+ * NAME: arm A 403 against arm B 200.
143
+ * ALL_PROXY / all_proxy - proxy for every scheme.
144
+ * FTP_PROXY / ftp_proxy - proxy for ftp:// requests.
145
+ *
146
+ * TWO NAMES ARE DELIBERATELY ABSENT, and both absences are measured rather
147
+ * than assumed. `NO_PROXY` / `no_proxy` NARROW reach instead of granting
148
+ * it, so listing them would redden a child that is strictly less capable.
149
+ * `CURL_CA_BUNDLE` is a TLS-trust channel and not an egress grant: M4-P8's
150
+ * arm C added it on top of arm B and measured the same HTTP 200 arm B
151
+ * already had (delivery/work-history/m4-p8.md:117), so it buys no reach,
152
+ * and its node-side sibling `NODE_EXTRA_CA_CERTS` is already covered by
153
+ * `DANGEROUS_ENV_VOCABULARY` above.
154
+ *
155
+ * LIKE THE LIST ABOVE THIS IS A BOUNDED DENYLIST. The allowlist in
156
+ * src/exec/env.ts is still the real defense; this makes a widening cost a
157
+ * red for the names walked here, it does not enumerate every way a child
158
+ * could be handed network reach.
159
+ */
160
+ export declare const EGRESS_ENV_VOCABULARY: readonly string[];
161
+ /** Whether a variable name is in the walked egress vocabulary. */
162
+ export declare function isEgressEnvName(name: string): boolean;
51
163
  /** Whether a variable name is in the walked dangerous vocabulary. */
52
164
  export declare function isDangerousEnvName(name: string): boolean;
165
+ /** One walked vocabulary, as a refusal walks it. */
166
+ export interface RefusedEnvVocabulary {
167
+ /** Stable id, used in tests and in nothing an operator reads. */
168
+ id: string;
169
+ /** The exported constant this row stands for, named for the drift test. */
170
+ constantName: string;
171
+ /** Whether this vocabulary claims the name. */
172
+ includes: (name: string) => boolean;
173
+ /**
174
+ * The middle of the refusal sentence: "the allowlist extension entry X "
175
+ * + this + " and may never cross into a child environment".
176
+ */
177
+ clause: string;
178
+ }
179
+ /**
180
+ * EVERY VOCABULARY A CHILD-ENVIRONMENT REFUSAL MUST WALK, IN ONE PLACE.
181
+ *
182
+ * THE MECHANISM THIS EXISTS AGAINST (CH-001 high, CR-F-CRED-003, CR-F-CRED-004;
183
+ * DR-0048): a refusal that walks ONE vocabulary, or a hand-picked subset of
184
+ * them, while several exist. `refuseExtraAllowlist` (src/exec/env.ts) named
185
+ * `GH_TOKEN_VARIABLES` and `isDangerousEnvName` as two literal `if` arms and
186
+ * therefore could not see `EGRESS_ENV_VOCABULARY`, which this module had held
187
+ * since M4-P29. The defect is not that the third name was forgotten once; it
188
+ * is that ADDING a vocabulary to this module left every consumer's coverage
189
+ * unchanged and silent, so the subset could only be discovered by probing a
190
+ * name.
191
+ *
192
+ * So the list of vocabularies is data, exported from the module that owns
193
+ * them, and a consumer walks the LIST rather than naming members of it. A
194
+ * fourth vocabulary added below is walked by every consumer of this array the
195
+ * moment it gains a row here, and `test/payload-credentials.test.ts` reddens
196
+ * if a `*_VOCABULARY` or `*_VARIABLES` export of this module has NO row,
197
+ * which is the case a reader cannot see by reading either file alone.
198
+ *
199
+ * `probeCredentialSources` deliberately does NOT walk this array: it reports a
200
+ * different sentence per vocabulary in one fixed order, strongest first, and the three
201
+ * sentences are read by operators. It is covered by the same drift test from
202
+ * the other side.
203
+ */
204
+ export declare const REFUSED_CHILD_ENV_VOCABULARIES: readonly RefusedEnvVocabulary[];
205
+ /**
206
+ * The first vocabulary claiming `name`, or undefined. The ORDER of
207
+ * `REFUSED_CHILD_ENV_VOCABULARIES` is the order the refusal reports, and it
208
+ * is narrowest-first so that a name in two vocabularies is reported as the
209
+ * more specific one.
210
+ */
211
+ export declare function refusedEnvVocabulary(name: string): RefusedEnvVocabulary | undefined;
53
212
  /** The names credential-scrub probes, in probe order. */
54
213
  export declare const CREDENTIAL_SOURCES: readonly string[];
55
214
  export interface ProbeOptions {
@@ -42,6 +42,14 @@ import { exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.j
42
42
  * ssh(1), node(1) and bash(1) document (DANGEROUS_ENV_VOCABULARY;
43
43
  * see that constant for the per-name source).
44
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
+ *
45
53
  * WHY THIS IS A TRIPWIRE, NOT THE SCRUB. The scrub is the allowlist in
46
54
  * src/exec/env.ts: nothing outside it can appear in a child by
47
55
  * construction, so the allowlist is the real defense. This tripwire is a
@@ -79,6 +87,25 @@ import { exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.j
79
87
  * (plan criterion 7, owner-blocked).
80
88
  */
81
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
+ */
82
109
  /**
83
110
  * gh's documented token vocabulary (see the module comment for the walk).
84
111
  * Never permitted in a child environment, allowlisted or not.
@@ -110,6 +137,27 @@ export const GH_TOKEN_VARIABLES = [
110
137
  * are matched by isDangerousEnvName's pattern.
111
138
  * ssh (ssh(1), ssh-add(1)):
112
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.
113
161
  * node / dynamic loader / shell startup (node(1), ld.so(8), bash(1)):
114
162
  * NODE_OPTIONS - options node applies at startup (can require
115
163
  * arbitrary modules), arbitrary code execution.
@@ -131,12 +179,96 @@ export const DANGEROUS_ENV_VOCABULARY = [
131
179
  "GIT_PROXY_COMMAND",
132
180
  "GIT_CONFIG_COUNT",
133
181
  "SSH_ASKPASS",
182
+ "SSH_AUTH_SOCK",
134
183
  "NODE_OPTIONS",
135
184
  "NODE_EXTRA_CA_CERTS",
136
185
  "LD_PRELOAD",
137
186
  "BASH_ENV",
138
187
  "ENV",
139
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
+ }
140
272
  /**
141
273
  * git-config(1)'s numbered environment config-injection members:
142
274
  * GIT_CONFIG_KEY_<n> and GIT_CONFIG_VALUE_<n> for n in [0, COUNT). The
@@ -149,6 +281,66 @@ export function isDangerousEnvName(name) {
149
281
  return (DANGEROUS_ENV_VOCABULARY.includes(name) ||
150
282
  GIT_CONFIG_INJECTION_MEMBER.test(name));
151
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
+ }
152
344
  /** The names credential-scrub probes, in probe order. */
153
345
  export const CREDENTIAL_SOURCES = [
154
346
  "environment",
@@ -222,6 +414,7 @@ export function probeCredentialSources(env, options = {}) {
222
414
  const names = Object.keys(env).filter((name) => env[name] !== undefined);
223
415
  const tokens = names.filter((name) => GH_TOKEN_VARIABLES.includes(name));
224
416
  const dangerous = names.filter((name) => isDangerousEnvName(name));
417
+ const egress = names.filter((name) => isEgressEnvName(name));
225
418
  const strays = names.filter((name) => !permitted.has(name));
226
419
  if (tokens.length > 0) {
227
420
  probes.push(probe("environment", "resolvable", `pull-request-capable token variable(s) present in the child environment: ${tokens.join(", ")}`));
@@ -229,11 +422,19 @@ export function probeCredentialSources(env, options = {}) {
229
422
  else if (dangerous.length > 0) {
230
423
  probes.push(probe("environment", "resolvable", `credential- or code-execution-capable variable(s) from the walked vocabulary present in the child environment: ${dangerous.join(", ")}`));
231
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
+ }
232
433
  else if (strays.length > 0) {
233
434
  probes.push(probe("environment", "resolvable", `variable(s) outside the constructed contract present in the child environment: ${strays.join(", ")}`));
234
435
  }
235
436
  else {
236
- probes.push(probe("environment", "clean", `${String(names.length)} variable(s), all inside the constructed contract, no gh token or walked-vocabulary variable`));
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`));
237
438
  }
238
439
  // Source 2: gh configuration. The store is hosts.yml at gh's documented
239
440
  // resolution order (GH_CONFIG_DIR, else XDG_CONFIG_HOME/gh, else
@@ -528,6 +729,24 @@ const entry = process.argv[1];
528
729
  if (entry !== undefined) {
529
730
  const isMain = pathsIdentifySameObject(fileURLToPath(import.meta.url), entry);
530
731
  if (isMain) {
531
- process.exit(gateMain(process.argv.slice(2)));
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));
532
751
  }
533
752
  }
@@ -0,0 +1,56 @@
1
+ /** DR-0029's three required classes, in the order the record reports them. */
2
+ declare const REQUIRED_CLASSES: readonly ["correctness", "scope", "review"];
3
+ type RequiredClass = (typeof REQUIRED_CLASSES)[number];
4
+ interface ClassDeclaration {
5
+ gates?: string[];
6
+ status?: string;
7
+ reason?: string;
8
+ establishedBy?: string;
9
+ }
10
+ type RegistryIdsResult = {
11
+ ok: true;
12
+ ids: Set<string>;
13
+ conditional: Set<string>;
14
+ } | {
15
+ ok: false;
16
+ reason: string;
17
+ };
18
+ /**
19
+ * The legal gate ids: every entry of this repository's own registry.
20
+ *
21
+ * A failure here is `error` at the call site and never a skipped check. The
22
+ * registry is DECODED rather than JSON-parsed because it ships as YAML and
23
+ * `decodeDocument` is the delivered decoder for both (DR-0013 YAML clause 3).
24
+ */
25
+ declare function registryGateIds(path: string): RegistryIdsResult;
26
+ interface ClassVerdict {
27
+ name: RequiredClass;
28
+ ok: boolean;
29
+ /** One sentence, printed on either arm. */
30
+ sentence: string;
31
+ /** True when this class is satisfied by a declared escape rather than a gate. */
32
+ escape: boolean;
33
+ /**
34
+ * The gate ids this class names that are `applicability: conditional`.
35
+ *
36
+ * NOT A FAILURE AND NOT AN ESCAPE, which is why it is a third field rather
37
+ * than a reuse of either (CR-FS-GATES-01). A conditional gate is a legitimate
38
+ * satisfier; what a reader is owed is that it can report not-applicable at a
39
+ * head and that nothing here checked whether it did.
40
+ */
41
+ conditionalGates: string[];
42
+ }
43
+ /**
44
+ * Judge ONE class. Every red arm names the class, so the detail a reader sees
45
+ * says which of the three is wrong rather than only that something is.
46
+ */
47
+ declare function judgeClass(name: RequiredClass, entry: ClassDeclaration | undefined, knownGateIds: Set<string>, conditionalGateIds?: Set<string>): ClassVerdict;
48
+ interface CompilerReading {
49
+ files: string[];
50
+ errors: string[];
51
+ }
52
+ /** Split a real `tsc --listFiles` capture into its file list and its errors. */
53
+ declare function readCompilerOutput(text: string): CompilerReading;
54
+ export declare function main(argv: string[]): number;
55
+ export { judgeClass, readCompilerOutput, registryGateIds, REQUIRED_CLASSES };
56
+ export type { ClassDeclaration, ClassVerdict, RequiredClass };