@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
@@ -61,6 +61,152 @@
61
61
  * knows, it does not replace this allowlist.
62
62
  */
63
63
  export declare const DEFAULT_CHILD_ENV_ALLOWLIST: readonly string[];
64
+ /**
65
+ * ONE PER-INVOCATION ALLOWLIST EXTENSION, AND THE REASON IT WAS GRANTED
66
+ * (M4-P8 step 3). The reason is DATA, not a comment beside the call site:
67
+ * a widening whose justification lives in a source comment is invisible to
68
+ * the record a later reader opens, and "an extension with no recorded
69
+ * reason" is one of this phase's declared hazard items. An empty reason is
70
+ * therefore a REFUSAL and never a permitted shorthand.
71
+ */
72
+ export interface ChildEnvExtension {
73
+ /** Exact variable name, same semantics as a default-allowlist entry. */
74
+ name: string;
75
+ /** Why this invocation may carry it. Non-empty; blank is refused. */
76
+ reason: string;
77
+ }
78
+ /**
79
+ * What an extension entry may be written as.
80
+ *
81
+ * THE BARE STRING IS THE PRE-M4-P8 FORM AND IT IS KEPT DELIBERATELY. The
82
+ * field has existed since M2-P8 as `readonly string[]` and the kernel's own
83
+ * tests model a widened allowlist with it (test/credentials-gate.test.ts).
84
+ * A string carries NO reason, so it cannot carry the audit half; both forms
85
+ * are refused identically for a dangerous NAME, which is the safety half.
86
+ *
87
+ * THE SENTENCE THAT USED TO STAND HERE WAS FALSE AND IS WITHDRAWN. It said a
88
+ * bare string "cannot satisfy the audited route: the route's entry point is
89
+ * `SpawnOptions.extraAllowlist`, which is typed `ChildEnvExtension[]` and
90
+ * cannot express one", and delivery/work-history/m4-p8.md item 8 repeated it.
91
+ * That is a COMPILE-TIME argument about a RUNTIME seam, and the same phase
92
+ * rejected exactly that argument one field over: test/payload-credentials.ts
93
+ * records that "the consumer that reaches this seam is a JavaScript plugin,
94
+ * and a missing field there is `undefined`, not a compile error". A clean-room
95
+ * review reproduced both a bare string and `{name}` with no `reason` crossing
96
+ * into a project payload through `spawnTask` (CR-B-002, HIGH), and this round
97
+ * re-reproduced both before changing anything. A type is not a guard at a seam
98
+ * a plugin reaches, and `refuseExtraAllowlist` with `reason-required` is the
99
+ * guard.
100
+ */
101
+ export type ChildEnvExtensionEntry = string | ChildEnvExtension;
102
+ /** The variable name an entry names, whichever form it is written in. */
103
+ export declare function extensionName(entry: ChildEnvExtensionEntry): string;
104
+ /** The recorded reason, or undefined for the bare-string form. */
105
+ export declare function extensionReason(entry: ChildEnvExtensionEntry): string | undefined;
106
+ /**
107
+ * WHETHER THIS CALLER DEMANDS A RECORDED REASON.
108
+ *
109
+ * NO DEFAULT, DELIBERATELY. A default here would be the very shape
110
+ * CR-B-002 is: an omitted argument silently taking the permissive arm.
111
+ * Every call site says which contract it is enforcing, and the two that
112
+ * exist say different things for reasons written at each of them.
113
+ */
114
+ export type ReasonRequirement = "reason-required" | "reason-optional";
115
+ /**
116
+ * REFUSE AN EXTENSION THE CHILD MUST NOT CARRY (M4-P8 step 4, criteria 3
117
+ * and 4).
118
+ *
119
+ * Until this phase `buildChildEnv` spread the extension into the copy loop
120
+ * unconditionally, so an extension naming `GH_TOKEN` crossed into a child
121
+ * and the `credential-scrub` gate stayed green, because that gate builds
122
+ * its OWN environment with no extension and probes the CONSTRUCTION rather
123
+ * than a real spawn. The allowlist is still the defense and it gains no
124
+ * name here; what this adds is that WIDENING it per invocation is checked
125
+ * against the same vocabulary the gate walks.
126
+ *
127
+ * ONE VOCABULARY, NOT TWO. `refusedEnvVocabulary`
128
+ * are IMPORTED from src/gates/credentials.ts rather than copied here or
129
+ * moved: plan step 4 offers move-or-re-export and requires the choice be
130
+ * recorded, and duplicating the walked vocabulary would create two lists
131
+ * that drift silently in the direction that matters. The import makes
132
+ * src/exec/env.ts and src/gates/credentials.ts a cycle, which is this
133
+ * repository's existing shape rather than a new one (thirteen cycles in
134
+ * `src/` at this branch's merge base, one of them src/spawn.ts to
135
+ * src/adapters/load.ts and back). The one rule the cycle imposes: NOTHING
136
+ * in this module may read an imported binding at module-evaluation time,
137
+ * only inside a function body, or the module loaded second hits the
138
+ * temporal dead zone. `test/payload-credentials.test.ts` imports both
139
+ * modules in both orders so that rule is checked rather than remembered.
140
+ *
141
+ * THE NAME CHECK IS ORDERED FIRST: a refused name is refused whatever reason
142
+ * accompanies it, so a persuasive reason can never buy a credential into a
143
+ * child.
144
+ *
145
+ * IT WALKS A DECLARED LIST OF VOCABULARIES, NOT A HAND-PICKED PAIR, AND THAT
146
+ * IS THE WHOLE OF CH-001 (clean-room-final-credential-hazard, HIGH) WITH
147
+ * CR-F-CRED-003 AND CR-F-CRED-004 (LOW) UNDER IT.
148
+ *
149
+ * Until this round the guard read two literal arms, `GH_TOKEN_VARIABLES` and
150
+ * `isDangerousEnvName`. src/gates/credentials.ts held a THIRD vocabulary,
151
+ * `EGRESS_ENV_VOCABULARY`, added by M4-P29 after that container measured
152
+ * `HTTPS_PROXY` as the single variable turning `api.github.com/user` from
153
+ * HTTP 403 into HTTP 200 inside a scrubbed child. The two arms could not see
154
+ * it, so the audited route ACCEPTED an extension naming the one variable this
155
+ * repository had twice measured to grant real GitHub reach, and it accepted
156
+ * `SSH_AUTH_SOCK`, an agent socket, because that sat in neither list either.
157
+ *
158
+ * The mechanism is general and is why the repair is not a third `if`: adding
159
+ * a vocabulary to the module that owns them left every consumer's coverage
160
+ * UNCHANGED AND SILENT, so a subset could only ever be found by probing a
161
+ * name. This walks `REFUSED_CHILD_ENV_VOCABULARIES`, which is that module's
162
+ * declared list of its own vocabularies, through `refusedEnvVocabulary`. A
163
+ * fourth vocabulary is walked here the moment it gains a row there, and
164
+ * test/payload-credentials.test.ts reddens on a `*_VOCABULARY` or
165
+ * `*_VARIABLES` export with no row.
166
+ *
167
+ * DR-0048 is the decision behind the egress half and it is not re-litigated
168
+ * here: the default scrubbed child carries seven names and no proxy, so
169
+ * refusing an egress extension withdraws nothing the design granted.
170
+ *
171
+ * THE REASON CHECK IS WRITTEN AS A POSITIVE VALIDITY TEST, AND THAT IS THE
172
+ * WHOLE OF FINDING CR-B-002 (clean-room-retro-B-criteria, HIGH).
173
+ *
174
+ * Until this round the guard read
175
+ * `reason !== undefined && reason.trim().length === 0`, which fires only on
176
+ * a PRESENT-but-blank reason. `extensionReason` returns `undefined` for a
177
+ * bare string entry and for an object with no `reason` property, so the
178
+ * first conjunct excused the absent case and the entry was ACCEPTED. The
179
+ * mechanism is general: a refusal predicate whose condition requires the
180
+ * value to be PRESENT leaves ABSENT unchecked, and the field this module's
181
+ * own doc comment calls mandatory ("every entry carries an exact name and a
182
+ * reason") is exactly the kind of field that reaches it as `undefined`.
183
+ *
184
+ * The repair is the spelling this repository already uses where it got this
185
+ * right: compute a POSITIVE `usable` predicate (src/cutover.ts:140 is the
186
+ * same shape, and `extensionName`'s `typeof name !== "string"` test two
187
+ * refusals above is the same shape again), then decide what to do with
188
+ * `!usable`. Absent, blank and non-string all reach `!usable` by different
189
+ * routes and the refusal SAYS which one it was, because an operator reading
190
+ * "carries no reason" about an entry that has one is looking for the wrong
191
+ * thing.
192
+ *
193
+ * `reasonRequirement` is what the two call sites differ on, and neither is
194
+ * a default:
195
+ *
196
+ * reason-required the AUDITED route (`checkCredentialPolicy` in
197
+ * src/spawn.ts). DR-0039 condition 2 and M4-P8
198
+ * criterion 4 are enforced here: an extension with no
199
+ * usable reason is refused before anything is created.
200
+ * reason-optional `buildChildEnv`, the pre-M4-P8 library seam, which
201
+ * documents the bare-string form and whose own tests
202
+ * model a widened allowlist with it. A blank reason is
203
+ * still refused there; an ABSENT one is the documented
204
+ * shorthand for "this caller records nothing", and the
205
+ * caller that must not be allowed that shorthand does
206
+ * not reach this module without passing through the
207
+ * audited route first.
208
+ */
209
+ export declare function refuseExtraAllowlist(entries: readonly ChildEnvExtensionEntry[], reasonRequirement: ReasonRequirement): string | undefined;
64
210
  /** One redirected credential-store pointer. */
65
211
  export interface CredentialRedirection {
66
212
  /** The environment variable name. */
@@ -83,7 +229,7 @@ export declare function scrubRoot(taskDir: string): string;
83
229
  * Every variable name the constructed environment may contain: the
84
230
  * allowlist, the per-invocation extension, and the redirected pointers.
85
231
  */
86
- export declare function permittedChildEnvNames(extraAllowlist?: readonly string[]): Set<string>;
232
+ export declare function permittedChildEnvNames(extraAllowlist?: readonly ChildEnvExtensionEntry[]): Set<string>;
87
233
  export interface ChildEnvSpec {
88
234
  /** The environment the values are copied FROM (usually process.env). */
89
235
  parentEnv: Record<string, string | undefined>;
@@ -96,8 +242,12 @@ export interface ChildEnvSpec {
96
242
  /**
97
243
  * Per-invocation allowlist extension (step 9's only obligation to the
98
244
  * future). Exact names, same semantics as the default list.
245
+ *
246
+ * SINCE M4-P8 EVERY ENTRY IS CHECKED before anything is staged: see
247
+ * `refuseExtraAllowlist` for the two refusals and for why the bare-string
248
+ * form is still accepted here while the audited route cannot produce one.
99
249
  */
100
- extraAllowlist?: readonly string[];
250
+ extraAllowlist?: readonly ChildEnvExtensionEntry[];
101
251
  }
102
252
  export type ChildEnvResult = {
103
253
  ok: true;
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { refusedEnvVocabulary } from "../gates/credentials.js";
3
4
  import { refuseOpenForWrite, runStep } from "../task.js";
4
5
  /**
5
6
  * CHILD-ENVIRONMENT CONSTRUCTION (kernel plan M2, M2-P8 steps 2 and 3).
@@ -90,6 +91,136 @@ export const DEFAULT_CHILD_ENV_ALLOWLIST = [
90
91
  "GIT_COMMITTER_EMAIL",
91
92
  "GIT_COMMITTER_DATE",
92
93
  ];
94
+ /** The variable name an entry names, whichever form it is written in. */
95
+ export function extensionName(entry) {
96
+ return typeof entry === "string" ? entry : entry.name;
97
+ }
98
+ /** The recorded reason, or undefined for the bare-string form. */
99
+ export function extensionReason(entry) {
100
+ return typeof entry === "string" ? undefined : entry.reason;
101
+ }
102
+ /**
103
+ * REFUSE AN EXTENSION THE CHILD MUST NOT CARRY (M4-P8 step 4, criteria 3
104
+ * and 4).
105
+ *
106
+ * Until this phase `buildChildEnv` spread the extension into the copy loop
107
+ * unconditionally, so an extension naming `GH_TOKEN` crossed into a child
108
+ * and the `credential-scrub` gate stayed green, because that gate builds
109
+ * its OWN environment with no extension and probes the CONSTRUCTION rather
110
+ * than a real spawn. The allowlist is still the defense and it gains no
111
+ * name here; what this adds is that WIDENING it per invocation is checked
112
+ * against the same vocabulary the gate walks.
113
+ *
114
+ * ONE VOCABULARY, NOT TWO. `refusedEnvVocabulary`
115
+ * are IMPORTED from src/gates/credentials.ts rather than copied here or
116
+ * moved: plan step 4 offers move-or-re-export and requires the choice be
117
+ * recorded, and duplicating the walked vocabulary would create two lists
118
+ * that drift silently in the direction that matters. The import makes
119
+ * src/exec/env.ts and src/gates/credentials.ts a cycle, which is this
120
+ * repository's existing shape rather than a new one (thirteen cycles in
121
+ * `src/` at this branch's merge base, one of them src/spawn.ts to
122
+ * src/adapters/load.ts and back). The one rule the cycle imposes: NOTHING
123
+ * in this module may read an imported binding at module-evaluation time,
124
+ * only inside a function body, or the module loaded second hits the
125
+ * temporal dead zone. `test/payload-credentials.test.ts` imports both
126
+ * modules in both orders so that rule is checked rather than remembered.
127
+ *
128
+ * THE NAME CHECK IS ORDERED FIRST: a refused name is refused whatever reason
129
+ * accompanies it, so a persuasive reason can never buy a credential into a
130
+ * child.
131
+ *
132
+ * IT WALKS A DECLARED LIST OF VOCABULARIES, NOT A HAND-PICKED PAIR, AND THAT
133
+ * IS THE WHOLE OF CH-001 (clean-room-final-credential-hazard, HIGH) WITH
134
+ * CR-F-CRED-003 AND CR-F-CRED-004 (LOW) UNDER IT.
135
+ *
136
+ * Until this round the guard read two literal arms, `GH_TOKEN_VARIABLES` and
137
+ * `isDangerousEnvName`. src/gates/credentials.ts held a THIRD vocabulary,
138
+ * `EGRESS_ENV_VOCABULARY`, added by M4-P29 after that container measured
139
+ * `HTTPS_PROXY` as the single variable turning `api.github.com/user` from
140
+ * HTTP 403 into HTTP 200 inside a scrubbed child. The two arms could not see
141
+ * it, so the audited route ACCEPTED an extension naming the one variable this
142
+ * repository had twice measured to grant real GitHub reach, and it accepted
143
+ * `SSH_AUTH_SOCK`, an agent socket, because that sat in neither list either.
144
+ *
145
+ * The mechanism is general and is why the repair is not a third `if`: adding
146
+ * a vocabulary to the module that owns them left every consumer's coverage
147
+ * UNCHANGED AND SILENT, so a subset could only ever be found by probing a
148
+ * name. This walks `REFUSED_CHILD_ENV_VOCABULARIES`, which is that module's
149
+ * declared list of its own vocabularies, through `refusedEnvVocabulary`. A
150
+ * fourth vocabulary is walked here the moment it gains a row there, and
151
+ * test/payload-credentials.test.ts reddens on a `*_VOCABULARY` or
152
+ * `*_VARIABLES` export with no row.
153
+ *
154
+ * DR-0048 is the decision behind the egress half and it is not re-litigated
155
+ * here: the default scrubbed child carries seven names and no proxy, so
156
+ * refusing an egress extension withdraws nothing the design granted.
157
+ *
158
+ * THE REASON CHECK IS WRITTEN AS A POSITIVE VALIDITY TEST, AND THAT IS THE
159
+ * WHOLE OF FINDING CR-B-002 (clean-room-retro-B-criteria, HIGH).
160
+ *
161
+ * Until this round the guard read
162
+ * `reason !== undefined && reason.trim().length === 0`, which fires only on
163
+ * a PRESENT-but-blank reason. `extensionReason` returns `undefined` for a
164
+ * bare string entry and for an object with no `reason` property, so the
165
+ * first conjunct excused the absent case and the entry was ACCEPTED. The
166
+ * mechanism is general: a refusal predicate whose condition requires the
167
+ * value to be PRESENT leaves ABSENT unchecked, and the field this module's
168
+ * own doc comment calls mandatory ("every entry carries an exact name and a
169
+ * reason") is exactly the kind of field that reaches it as `undefined`.
170
+ *
171
+ * The repair is the spelling this repository already uses where it got this
172
+ * right: compute a POSITIVE `usable` predicate (src/cutover.ts:140 is the
173
+ * same shape, and `extensionName`'s `typeof name !== "string"` test two
174
+ * refusals above is the same shape again), then decide what to do with
175
+ * `!usable`. Absent, blank and non-string all reach `!usable` by different
176
+ * routes and the refusal SAYS which one it was, because an operator reading
177
+ * "carries no reason" about an entry that has one is looking for the wrong
178
+ * thing.
179
+ *
180
+ * `reasonRequirement` is what the two call sites differ on, and neither is
181
+ * a default:
182
+ *
183
+ * reason-required the AUDITED route (`checkCredentialPolicy` in
184
+ * src/spawn.ts). DR-0039 condition 2 and M4-P8
185
+ * criterion 4 are enforced here: an extension with no
186
+ * usable reason is refused before anything is created.
187
+ * reason-optional `buildChildEnv`, the pre-M4-P8 library seam, which
188
+ * documents the bare-string form and whose own tests
189
+ * model a widened allowlist with it. A blank reason is
190
+ * still refused there; an ABSENT one is the documented
191
+ * shorthand for "this caller records nothing", and the
192
+ * caller that must not be allowed that shorthand does
193
+ * not reach this module without passing through the
194
+ * audited route first.
195
+ */
196
+ export function refuseExtraAllowlist(entries, reasonRequirement) {
197
+ for (const entry of entries) {
198
+ const name = extensionName(entry);
199
+ if (typeof name !== "string" || name.length === 0) {
200
+ return (`an allowlist extension entry names no variable ` +
201
+ `(${JSON.stringify(entry)}); every entry carries an exact name and a reason`);
202
+ }
203
+ const refusedBy = refusedEnvVocabulary(name);
204
+ if (refusedBy !== undefined) {
205
+ return `the allowlist extension entry ${name} ${refusedBy.clause}`;
206
+ }
207
+ const reason = extensionReason(entry);
208
+ const usable = typeof reason === "string" && reason.trim().length > 0;
209
+ if (!usable) {
210
+ const shape = reason === undefined
211
+ ? "no reason field at all"
212
+ : typeof reason !== "string"
213
+ ? `a reason that is not a string (${JSON.stringify(reason)})`
214
+ : `a blank reason (${JSON.stringify(reason)})`;
215
+ if (reasonRequirement === "reason-required" || reason !== undefined) {
216
+ return (`the allowlist extension entry ${name} carries ${shape}; an extension ` +
217
+ `is an audited widening and a reason that is absent, blank or not a ` +
218
+ `string records nothing a later reader could check`);
219
+ }
220
+ }
221
+ }
222
+ return undefined;
223
+ }
93
224
  /**
94
225
  * The five pointers, redirected in this order. Each names a credential
95
226
  * store's location; see the module comment for why none may be dropped.
@@ -114,7 +245,7 @@ export function scrubRoot(taskDir) {
114
245
  export function permittedChildEnvNames(extraAllowlist = []) {
115
246
  return new Set([
116
247
  ...DEFAULT_CHILD_ENV_ALLOWLIST,
117
- ...extraAllowlist,
248
+ ...extraAllowlist.map(extensionName),
118
249
  ...CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name),
119
250
  "GIT_CONFIG_NOSYSTEM",
120
251
  ]);
@@ -139,6 +270,19 @@ export function permittedChildEnvNames(extraAllowlist = []) {
139
270
  * credential path, so there is no partial success here (fail closed).
140
271
  */
141
272
  export function buildChildEnv(spec) {
273
+ // THE EXTENSION IS CHECKED FIRST, BEFORE ANY DIRECTORY IS MADE (M4-P8
274
+ // criteria 3 and 4). A refusal that had already staged a scrub root would
275
+ // leave the caller's rollback holding something this call created, and
276
+ // the whole point of refusing here is that a rejected widening costs
277
+ // nothing and changes nothing.
278
+ // `reason-optional`, and the argument is passed rather than defaulted: see
279
+ // `ReasonRequirement`. This seam predates M4-P8 and documents the
280
+ // bare-string form; the audited route demands a reason one layer up, in
281
+ // `checkCredentialPolicy`, before anything is created.
282
+ const refusal = refuseExtraAllowlist(spec.extraAllowlist ?? [], "reason-optional");
283
+ if (refusal !== undefined) {
284
+ return { ok: false, reason: refusal };
285
+ }
142
286
  const made = runStep(`creating the scrub root ${spec.scrubDir}`, () => mkdirSync(spec.scrubDir, { recursive: true }));
143
287
  if (!made.ok) {
144
288
  return { ok: false, reason: made.reason };
@@ -146,7 +290,7 @@ export function buildChildEnv(spec) {
146
290
  const env = {};
147
291
  const names = [
148
292
  ...DEFAULT_CHILD_ENV_ALLOWLIST,
149
- ...(spec.extraAllowlist ?? []),
293
+ ...(spec.extraAllowlist ?? []).map(extensionName),
150
294
  ];
151
295
  for (const name of names) {
152
296
  const value = spec.parentEnv[name];
@@ -49,3 +49,175 @@ export declare function missingLayoutEntries(dir: string): string[];
49
49
  * naming every missing entry when the layout is incomplete.
50
50
  */
51
51
  export declare function loadFleet(dir: string): Fleet;
52
+ /**
53
+ * A layout entry's type, established BEFORE anything is done with the path.
54
+ * `classifyEntry` in src/task.ts answers "may this be opened as a regular
55
+ * file" and therefore calls a directory irregular, which is the wrong answer
56
+ * for a layout entry: here a directory is the wanted shape. Same discipline,
57
+ * different question, so it is a separate function rather than a flag on that
58
+ * one (plan constraint C-2 is unaffected; nothing here probes a process).
59
+ */
60
+ export type LayoutEntryClass =
61
+ /** Nothing at the path. */
62
+ {
63
+ kind: "absent";
64
+ }
65
+ /** A directory, or a symlink resolving to one. */
66
+ | {
67
+ kind: "directory";
68
+ }
69
+ /** Present and not a directory, or a symlink resolving to nothing. */
70
+ | {
71
+ kind: "other";
72
+ reason: string;
73
+ }
74
+ /** Neither lstat nor stat could answer the question. */
75
+ | {
76
+ kind: "unexaminable";
77
+ reason: string;
78
+ };
79
+ /**
80
+ * Classify a layout path without opening it. lstat first, so a symlink is
81
+ * seen as a symlink; then stat, so a symlink to a directory is a directory
82
+ * and a dangling one is reported as such rather than as absent.
83
+ */
84
+ export declare function classifyLayoutEntry(path: string): LayoutEntryClass;
85
+ /**
86
+ * The EPHEMERAL directories: exactly the gitignored set, with the trailing
87
+ * slash that `.gitignore` needs stripped off. DERIVED from FLEET_IGNORED
88
+ * rather than listed again, because a second list is a second thing to keep
89
+ * in step and the first divergence would be silent: `tiphys resume` would
90
+ * rebuild one set while `.gitignore` ignored another.
91
+ */
92
+ export declare const EPHEMERAL_DIRS: readonly string[];
93
+ /**
94
+ * The DURABLE directories: every fleet directory that is not ephemeral.
95
+ * A clone of a fleet home carries these and not the ephemeral ones, which
96
+ * is the fact `tiphys resume` exists to act on.
97
+ */
98
+ export declare const DURABLE_DIRS: readonly string[];
99
+ /**
100
+ * The durable layout entries missing from dir, in declaration order:
101
+ * directories first with a trailing slash, then the root files. An empty
102
+ * result means the directory carries everything a clone of a fleet home
103
+ * carries, which is the precondition `tiphys resume` requires and never
104
+ * fabricates.
105
+ */
106
+ export declare function missingDurableEntries(dir: string): string[];
107
+ /**
108
+ * THE GUARDED OPEN, AT THE BOTTOM OF THE IMPORT GRAPH.
109
+ *
110
+ * `classifyEntry` in src/task.ts:118 asks exactly this question and its own
111
+ * docblock says where it belongs: "a general filesystem rule and not a task
112
+ * rule, and a dedicated module would be its right home. This module is the
113
+ * lowest one in the import graph that the fix round authorized to touch."
114
+ * This module is LOWER. src/task.ts imports src/lock.ts, src/lock.ts imports
115
+ * src/exclusion.ts, and this file imports nothing from the project at all, so
116
+ * the two modules that hold the kernel's lease and register reads CANNOT
117
+ * reach src/task.ts without making the first import cycle in `src/` (measured
118
+ * at this head: the graph is a strict DAG). They reach these instead.
119
+ *
120
+ * THE DUPLICATION IS REAL AND IT IS NAMED RATHER THAN HIDDEN. Two
121
+ * implementations of one question can drift, and the end state is src/task.ts
122
+ * re-exporting these. That edit is not in this round's declared file set, so
123
+ * it is escalated rather than made, and until it happens the vocabulary, the
124
+ * branch order and the refusal TEXT below are kept identical to src/task.ts's
125
+ * on purpose: doctor already prints "is a named pipe, not a regular file, so
126
+ * it was not opened" for a lease, and a second sentence for the same state
127
+ * would make two true reports read as two different conditions.
128
+ *
129
+ * C-2 is unaffected: lstat and stat are questions about a directory entry,
130
+ * never about a running program.
131
+ */
132
+ export type PathEntryClass =
133
+ /** Nothing at the path. */
134
+ {
135
+ kind: "absent";
136
+ }
137
+ /** A link is there and resolves to nothing: it exists, and it is empty of evidence. */
138
+ | {
139
+ kind: "dangling";
140
+ }
141
+ /** Safe to open. */
142
+ | {
143
+ kind: "regular";
144
+ }
145
+ /** Present, and opening it is not safe: never opened, always named. */
146
+ | {
147
+ kind: "irregular";
148
+ reason: string;
149
+ }
150
+ /** Neither lstat nor stat could answer the question. */
151
+ | {
152
+ kind: "unexaminable";
153
+ reason: string;
154
+ };
155
+ /**
156
+ * lstat first, so a symlink is seen as a symlink; then stat, so a symlink to
157
+ * a regular file is regular and a dangling one is reported as such rather
158
+ * than as absent. The path is never opened.
159
+ */
160
+ export declare function classifyPathEntry(path: string): PathEntryClass;
161
+ /** What a guarded read of a possibly-absent path produced. */
162
+ export type RegularPathRead = {
163
+ kind: "read";
164
+ body: string;
165
+ } | {
166
+ kind: "absent";
167
+ }
168
+ /** Present and not readable, with a reason naming the path. */
169
+ | {
170
+ kind: "refused";
171
+ reason: string;
172
+ };
173
+ /** THE ONE READ of a path that might not be there and might not be a file. */
174
+ export declare function readRegularPathIfPresent(path: string): RegularPathRead;
175
+ /**
176
+ * Refuse an open-for-WRITE of a path that is not a regular file. The hazard
177
+ * is symmetric: open(2) for writing on a FIFO with no reader blocks exactly
178
+ * as reading one with no writer does, so a staged write is as dangerous as a
179
+ * read. Returns the reason, or undefined when the path may be opened (absent
180
+ * included: creating it is the point).
181
+ */
182
+ export declare function refuseOpenPathForWrite(path: string): string | undefined;
183
+ /**
184
+ * THE KERNEL'S OWN WRITE-THEN-RENAME SCRATCH SUFFIXES.
185
+ *
186
+ * `FLEET_IGNORED` above is a DENYLIST of three directory prefixes, and
187
+ * `tiphys sync` derives "durable" as "not covered by it". That derivation is
188
+ * right for everything the ignore rules were written to cover and blind to a
189
+ * class they were never asked about: a transient artifact the KERNEL ITSELF
190
+ * creates beside a TRACKED target, which git therefore reports as an
191
+ * ordinary new durable path.
192
+ *
193
+ * The enumeration behind this list is every path the kernel writes under a
194
+ * fleet home, classified against `FLEET_IGNORED`. The three that land
195
+ * DURABLE without being fleet content are:
196
+ *
197
+ * status/current.json.tmp src/status.ts:142, a fixed name inside the
198
+ * tracked status/ directory.
199
+ * .cutover.<random>.tmp src/cutover.ts:278, a dot-prefixed random
200
+ * name at the fleet ROOT.
201
+ * tiphys-environment.json src/exclusion.ts:303, durable ON PURPOSE
202
+ * (M4-P21 criterion 3) and therefore NOT in
203
+ * this list.
204
+ *
205
+ * Two members, two directories, two naming shapes, which is why the rule is
206
+ * a SUFFIX and not a filename: a rule naming `current.json.tmp` would close
207
+ * the first and leave the second open.
208
+ *
209
+ * `FLEET_IGNORED` IS DELIBERATELY UNCHANGED. It drives `EPHEMERAL_DIRS`,
210
+ * `DURABLE_DIRS` and the `.gitignore` that `tiphys init` writes, so a glob
211
+ * added there would become a directory name `tiphys resume` tried to rebuild.
212
+ * Nothing stops being synced because of this constant; `tiphys sync` gains a
213
+ * refusal, and only for paths matching a suffix below.
214
+ */
215
+ export declare const FLEET_SCRATCH_SUFFIXES: readonly string[];
216
+ /**
217
+ * True when a fleet-relative path is a kernel scratch artifact by its name
218
+ * alone. Name-only on purpose: `tiphys sync` asks this about a path git
219
+ * REPORTED, which may already have been renamed away by the time the
220
+ * question is asked, so a stat here would answer about a different world
221
+ * than the one being committed.
222
+ */
223
+ export declare function isFleetScratchPath(relativePath: string): string | undefined;