@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.
- package/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CHILD-ENVIRONMENT CONSTRUCTION (kernel plan M2, M2-P8 steps 2 and 3).
|
|
3
|
+
*
|
|
4
|
+
* Every child the executor launches (the payload and the turn-end hook)
|
|
5
|
+
* receives an environment BUILT here, never inherited. The build is an
|
|
6
|
+
* ALLOWLIST (M2-D-13): a variable crosses into the child only when its
|
|
7
|
+
* exact name is in the list below or in the caller's per-invocation
|
|
8
|
+
* extension. There is no denylist anywhere in this module, because a
|
|
9
|
+
* denylist is a union of the credential names known on the day it was
|
|
10
|
+
* written and grows stale silently; an allowlist fails in the safe
|
|
11
|
+
* direction (a missing variable is a visible breakage, a leaked one is
|
|
12
|
+
* not).
|
|
13
|
+
*
|
|
14
|
+
* REDIRECT, NEVER DROP, THE CREDENTIAL-STORE POINTERS (M2R-004 edit 1).
|
|
15
|
+
* `HOME`, `XDG_CONFIG_HOME`, `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL` and
|
|
16
|
+
* `GIT_CONFIG_SYSTEM` are not simply excluded from the allowlist; they are
|
|
17
|
+
* SET to harness-owned empty paths inside the task directory. Dropping any
|
|
18
|
+
* of them is FORBIDDEN, and this comment is the record of why:
|
|
19
|
+
*
|
|
20
|
+
* - A dropped `HOME` does not remove the home directory; the child's
|
|
21
|
+
* tools fall back to the real one (or to `/root`), so gh resolves
|
|
22
|
+
* `$HOME/.config/gh/hosts.yml`, git resolves `~/.gitconfig`,
|
|
23
|
+
* `~/.netrc` and `~/.git-credentials`, and every default credential
|
|
24
|
+
* path RETURNS. An environment-only scrub that drops pointers leaves
|
|
25
|
+
* the credential stores in place, which is exactly finding M2R-004.
|
|
26
|
+
* - A dropped `GIT_CONFIG_GLOBAL` additionally hands the child the
|
|
27
|
+
* user's real global git configuration, undoing the EXT-F-02
|
|
28
|
+
* discipline (v1 M1-P2 criterion 7): the kernel never reads and never
|
|
29
|
+
* writes user or global git config, and commit identity crosses as
|
|
30
|
+
* command-scoped GIT_AUTHOR_* / GIT_COMMITTER_* variables only.
|
|
31
|
+
*
|
|
32
|
+
* THE ALLOWLIST IS DATA, NOT A LITERAL INSIDE A SPAWN CALL (step 9). It is
|
|
33
|
+
* exported and `buildChildEnv` takes a per-invocation `extraAllowlist`, so
|
|
34
|
+
* a future caller (the M4-era release-verification wiring designed in
|
|
35
|
+
* delivery/verification/release-verification-interface.md section 6.1)
|
|
36
|
+
* can extend it per invocation and per adapter without touching this
|
|
37
|
+
* module. Nothing in this module knows about release verification; that
|
|
38
|
+
* is deliberate and this phase builds no such awareness.
|
|
39
|
+
*
|
|
40
|
+
* WHY THE THREE TIPHYS_EXIT_TEST_* NAMES ARE HERE. The delivered payload
|
|
41
|
+
* contract (scripts/stub-payload.sh, its documented environment contract)
|
|
42
|
+
* is the one place variables cross the spawn boundary into a payload
|
|
43
|
+
* today, and it names exactly these three. They are listed as exact names
|
|
44
|
+
* rather than as a TIPHYS_* prefix on purpose: a prefix rule would carry
|
|
45
|
+
* ANY variable that happens to start with TIPHYS_, which is a denylist's
|
|
46
|
+
* failure mode wearing an allowlist's name (and is what acceptance
|
|
47
|
+
* criterion 2's TIPHYS_UNRELATED_SECRET witness refutes).
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* The default allowlist. Exact names only; no prefixes, no patterns.
|
|
51
|
+
* Append here only with a recorded reason, and never a credential-capable
|
|
52
|
+
* name. This allowlist is the REAL defense: a name crosses only if it is
|
|
53
|
+
* here (or in a per-invocation extension), so nothing outside it appears
|
|
54
|
+
* in a child by construction. `credential-scrub` (src/gates/credentials.ts)
|
|
55
|
+
* adds a bounded, allowlist-INDEPENDENT tripwire on top: it reddens if a
|
|
56
|
+
* gh-documented token variable OR a git/ssh/node credential- or
|
|
57
|
+
* code-execution-capable variable (its documented vocabulary) is present
|
|
58
|
+
* in the constructed child, even if some future edit wrongly adds one
|
|
59
|
+
* here. That tripwire is a bounded denylist and cannot enumerate every
|
|
60
|
+
* dangerous name; it makes a widened allowlist cost a red for the names it
|
|
61
|
+
* knows, it does not replace this allowlist.
|
|
62
|
+
*/
|
|
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;
|
|
210
|
+
/** One redirected credential-store pointer. */
|
|
211
|
+
export interface CredentialRedirection {
|
|
212
|
+
/** The environment variable name. */
|
|
213
|
+
name: string;
|
|
214
|
+
/** Whether the harness-owned target is a directory or a file. */
|
|
215
|
+
kind: "directory" | "file";
|
|
216
|
+
/** Path of the target relative to the scrub root. */
|
|
217
|
+
relativePath: string;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* The five pointers, redirected in this order. Each names a credential
|
|
221
|
+
* store's location; see the module comment for why none may be dropped.
|
|
222
|
+
*/
|
|
223
|
+
export declare const CREDENTIAL_STORE_REDIRECTIONS: readonly CredentialRedirection[];
|
|
224
|
+
/** Directory name of the scrub root inside a task directory. */
|
|
225
|
+
export declare const SCRUB_DIR_NAME = "scrub-env";
|
|
226
|
+
/** Where spawn puts the harness-owned redirect targets for a task. */
|
|
227
|
+
export declare function scrubRoot(taskDir: string): string;
|
|
228
|
+
/**
|
|
229
|
+
* Every variable name the constructed environment may contain: the
|
|
230
|
+
* allowlist, the per-invocation extension, and the redirected pointers.
|
|
231
|
+
*/
|
|
232
|
+
export declare function permittedChildEnvNames(extraAllowlist?: readonly ChildEnvExtensionEntry[]): Set<string>;
|
|
233
|
+
export interface ChildEnvSpec {
|
|
234
|
+
/** The environment the values are copied FROM (usually process.env). */
|
|
235
|
+
parentEnv: Record<string, string | undefined>;
|
|
236
|
+
/**
|
|
237
|
+
* Absolute path of the harness-owned scrub root. Created (with its five
|
|
238
|
+
* redirect targets) by this call; must sit inside a directory this
|
|
239
|
+
* invocation owns, which for spawn is the task directory.
|
|
240
|
+
*/
|
|
241
|
+
scrubDir: string;
|
|
242
|
+
/**
|
|
243
|
+
* Per-invocation allowlist extension (step 9's only obligation to the
|
|
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.
|
|
249
|
+
*/
|
|
250
|
+
extraAllowlist?: readonly ChildEnvExtensionEntry[];
|
|
251
|
+
}
|
|
252
|
+
export type ChildEnvResult = {
|
|
253
|
+
ok: true;
|
|
254
|
+
env: Record<string, string>;
|
|
255
|
+
} | {
|
|
256
|
+
ok: false;
|
|
257
|
+
reason: string;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Build the child environment: create the scrub root and its five EMPTY
|
|
261
|
+
* redirect targets, copy the allowlisted names that are present in the
|
|
262
|
+
* parent environment, then OVERRIDE the five pointers with the
|
|
263
|
+
* harness-owned paths. The override runs last and unconditionally, so a
|
|
264
|
+
* pointer that is also (wrongly) allowlisted still ends up redirected.
|
|
265
|
+
*
|
|
266
|
+
* EVERY redirect target is re-emptied on every build, directory and file
|
|
267
|
+
* alike: a leftover store file from a prior same-taskId incarnation (a
|
|
268
|
+
* .git-credentials or hosts.yml written into a directory target, or a
|
|
269
|
+
* populated gitconfig-global file) must not survive a rebuild and smuggle
|
|
270
|
+
* configuration into the next child (M2-P8 fix round 1, review finding O1).
|
|
271
|
+
* File targets are rewritten empty; directory targets are removed and
|
|
272
|
+
* recreated so they are empty too.
|
|
273
|
+
*
|
|
274
|
+
* A failure to stage any redirect target fails the whole construction:
|
|
275
|
+
* an unredirected pointer would silently fall back to the default
|
|
276
|
+
* credential path, so there is no partial success here (fail closed).
|
|
277
|
+
*/
|
|
278
|
+
export declare function buildChildEnv(spec: ChildEnvSpec): ChildEnvResult;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { refusedEnvVocabulary } from "../gates/credentials.js";
|
|
4
|
+
import { refuseOpenForWrite, runStep } from "../task.js";
|
|
5
|
+
/**
|
|
6
|
+
* CHILD-ENVIRONMENT CONSTRUCTION (kernel plan M2, M2-P8 steps 2 and 3).
|
|
7
|
+
*
|
|
8
|
+
* Every child the executor launches (the payload and the turn-end hook)
|
|
9
|
+
* receives an environment BUILT here, never inherited. The build is an
|
|
10
|
+
* ALLOWLIST (M2-D-13): a variable crosses into the child only when its
|
|
11
|
+
* exact name is in the list below or in the caller's per-invocation
|
|
12
|
+
* extension. There is no denylist anywhere in this module, because a
|
|
13
|
+
* denylist is a union of the credential names known on the day it was
|
|
14
|
+
* written and grows stale silently; an allowlist fails in the safe
|
|
15
|
+
* direction (a missing variable is a visible breakage, a leaked one is
|
|
16
|
+
* not).
|
|
17
|
+
*
|
|
18
|
+
* REDIRECT, NEVER DROP, THE CREDENTIAL-STORE POINTERS (M2R-004 edit 1).
|
|
19
|
+
* `HOME`, `XDG_CONFIG_HOME`, `GH_CONFIG_DIR`, `GIT_CONFIG_GLOBAL` and
|
|
20
|
+
* `GIT_CONFIG_SYSTEM` are not simply excluded from the allowlist; they are
|
|
21
|
+
* SET to harness-owned empty paths inside the task directory. Dropping any
|
|
22
|
+
* of them is FORBIDDEN, and this comment is the record of why:
|
|
23
|
+
*
|
|
24
|
+
* - A dropped `HOME` does not remove the home directory; the child's
|
|
25
|
+
* tools fall back to the real one (or to `/root`), so gh resolves
|
|
26
|
+
* `$HOME/.config/gh/hosts.yml`, git resolves `~/.gitconfig`,
|
|
27
|
+
* `~/.netrc` and `~/.git-credentials`, and every default credential
|
|
28
|
+
* path RETURNS. An environment-only scrub that drops pointers leaves
|
|
29
|
+
* the credential stores in place, which is exactly finding M2R-004.
|
|
30
|
+
* - A dropped `GIT_CONFIG_GLOBAL` additionally hands the child the
|
|
31
|
+
* user's real global git configuration, undoing the EXT-F-02
|
|
32
|
+
* discipline (v1 M1-P2 criterion 7): the kernel never reads and never
|
|
33
|
+
* writes user or global git config, and commit identity crosses as
|
|
34
|
+
* command-scoped GIT_AUTHOR_* / GIT_COMMITTER_* variables only.
|
|
35
|
+
*
|
|
36
|
+
* THE ALLOWLIST IS DATA, NOT A LITERAL INSIDE A SPAWN CALL (step 9). It is
|
|
37
|
+
* exported and `buildChildEnv` takes a per-invocation `extraAllowlist`, so
|
|
38
|
+
* a future caller (the M4-era release-verification wiring designed in
|
|
39
|
+
* delivery/verification/release-verification-interface.md section 6.1)
|
|
40
|
+
* can extend it per invocation and per adapter without touching this
|
|
41
|
+
* module. Nothing in this module knows about release verification; that
|
|
42
|
+
* is deliberate and this phase builds no such awareness.
|
|
43
|
+
*
|
|
44
|
+
* WHY THE THREE TIPHYS_EXIT_TEST_* NAMES ARE HERE. The delivered payload
|
|
45
|
+
* contract (scripts/stub-payload.sh, its documented environment contract)
|
|
46
|
+
* is the one place variables cross the spawn boundary into a payload
|
|
47
|
+
* today, and it names exactly these three. They are listed as exact names
|
|
48
|
+
* rather than as a TIPHYS_* prefix on purpose: a prefix rule would carry
|
|
49
|
+
* ANY variable that happens to start with TIPHYS_, which is a denylist's
|
|
50
|
+
* failure mode wearing an allowlist's name (and is what acceptance
|
|
51
|
+
* criterion 2's TIPHYS_UNRELATED_SECRET witness refutes).
|
|
52
|
+
*/
|
|
53
|
+
/**
|
|
54
|
+
* The default allowlist. Exact names only; no prefixes, no patterns.
|
|
55
|
+
* Append here only with a recorded reason, and never a credential-capable
|
|
56
|
+
* name. This allowlist is the REAL defense: a name crosses only if it is
|
|
57
|
+
* here (or in a per-invocation extension), so nothing outside it appears
|
|
58
|
+
* in a child by construction. `credential-scrub` (src/gates/credentials.ts)
|
|
59
|
+
* adds a bounded, allowlist-INDEPENDENT tripwire on top: it reddens if a
|
|
60
|
+
* gh-documented token variable OR a git/ssh/node credential- or
|
|
61
|
+
* code-execution-capable variable (its documented vocabulary) is present
|
|
62
|
+
* in the constructed child, even if some future edit wrongly adds one
|
|
63
|
+
* here. That tripwire is a bounded denylist and cannot enumerate every
|
|
64
|
+
* dangerous name; it makes a widened allowlist cost a red for the names it
|
|
65
|
+
* knows, it does not replace this allowlist.
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_CHILD_ENV_ALLOWLIST = [
|
|
68
|
+
// Program resolution and scratch space.
|
|
69
|
+
"PATH",
|
|
70
|
+
"TMPDIR",
|
|
71
|
+
// Locale, so a child's tool output is not re-localised mid-suite.
|
|
72
|
+
"LANG",
|
|
73
|
+
"LANGUAGE",
|
|
74
|
+
"LC_ALL",
|
|
75
|
+
"LC_COLLATE",
|
|
76
|
+
"LC_CTYPE",
|
|
77
|
+
"LC_MESSAGES",
|
|
78
|
+
"LC_MONETARY",
|
|
79
|
+
"LC_NUMERIC",
|
|
80
|
+
"LC_TIME",
|
|
81
|
+
// The payload contract of the exit-test harness (see module comment).
|
|
82
|
+
"TIPHYS_EXIT_TEST_MODE",
|
|
83
|
+
"TIPHYS_EXIT_TEST_TASK",
|
|
84
|
+
"TIPHYS_EXIT_TEST_REPORT",
|
|
85
|
+
// Command-scoped git identity (EXT-F-02 option B): the ONLY sanctioned
|
|
86
|
+
// way identity reaches a child, and never a credential.
|
|
87
|
+
"GIT_AUTHOR_NAME",
|
|
88
|
+
"GIT_AUTHOR_EMAIL",
|
|
89
|
+
"GIT_AUTHOR_DATE",
|
|
90
|
+
"GIT_COMMITTER_NAME",
|
|
91
|
+
"GIT_COMMITTER_EMAIL",
|
|
92
|
+
"GIT_COMMITTER_DATE",
|
|
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
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The five pointers, redirected in this order. Each names a credential
|
|
226
|
+
* store's location; see the module comment for why none may be dropped.
|
|
227
|
+
*/
|
|
228
|
+
export const CREDENTIAL_STORE_REDIRECTIONS = [
|
|
229
|
+
{ name: "HOME", kind: "directory", relativePath: "home" },
|
|
230
|
+
{ name: "XDG_CONFIG_HOME", kind: "directory", relativePath: "xdg-config" },
|
|
231
|
+
{ name: "GH_CONFIG_DIR", kind: "directory", relativePath: "gh-config" },
|
|
232
|
+
{ name: "GIT_CONFIG_GLOBAL", kind: "file", relativePath: "gitconfig-global" },
|
|
233
|
+
{ name: "GIT_CONFIG_SYSTEM", kind: "file", relativePath: "gitconfig-system" },
|
|
234
|
+
];
|
|
235
|
+
/** Directory name of the scrub root inside a task directory. */
|
|
236
|
+
export const SCRUB_DIR_NAME = "scrub-env";
|
|
237
|
+
/** Where spawn puts the harness-owned redirect targets for a task. */
|
|
238
|
+
export function scrubRoot(taskDir) {
|
|
239
|
+
return join(taskDir, SCRUB_DIR_NAME);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Every variable name the constructed environment may contain: the
|
|
243
|
+
* allowlist, the per-invocation extension, and the redirected pointers.
|
|
244
|
+
*/
|
|
245
|
+
export function permittedChildEnvNames(extraAllowlist = []) {
|
|
246
|
+
return new Set([
|
|
247
|
+
...DEFAULT_CHILD_ENV_ALLOWLIST,
|
|
248
|
+
...extraAllowlist.map(extensionName),
|
|
249
|
+
...CREDENTIAL_STORE_REDIRECTIONS.map((redirection) => redirection.name),
|
|
250
|
+
"GIT_CONFIG_NOSYSTEM",
|
|
251
|
+
]);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Build the child environment: create the scrub root and its five EMPTY
|
|
255
|
+
* redirect targets, copy the allowlisted names that are present in the
|
|
256
|
+
* parent environment, then OVERRIDE the five pointers with the
|
|
257
|
+
* harness-owned paths. The override runs last and unconditionally, so a
|
|
258
|
+
* pointer that is also (wrongly) allowlisted still ends up redirected.
|
|
259
|
+
*
|
|
260
|
+
* EVERY redirect target is re-emptied on every build, directory and file
|
|
261
|
+
* alike: a leftover store file from a prior same-taskId incarnation (a
|
|
262
|
+
* .git-credentials or hosts.yml written into a directory target, or a
|
|
263
|
+
* populated gitconfig-global file) must not survive a rebuild and smuggle
|
|
264
|
+
* configuration into the next child (M2-P8 fix round 1, review finding O1).
|
|
265
|
+
* File targets are rewritten empty; directory targets are removed and
|
|
266
|
+
* recreated so they are empty too.
|
|
267
|
+
*
|
|
268
|
+
* A failure to stage any redirect target fails the whole construction:
|
|
269
|
+
* an unredirected pointer would silently fall back to the default
|
|
270
|
+
* credential path, so there is no partial success here (fail closed).
|
|
271
|
+
*/
|
|
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
|
+
}
|
|
286
|
+
const made = runStep(`creating the scrub root ${spec.scrubDir}`, () => mkdirSync(spec.scrubDir, { recursive: true }));
|
|
287
|
+
if (!made.ok) {
|
|
288
|
+
return { ok: false, reason: made.reason };
|
|
289
|
+
}
|
|
290
|
+
const env = {};
|
|
291
|
+
const names = [
|
|
292
|
+
...DEFAULT_CHILD_ENV_ALLOWLIST,
|
|
293
|
+
...(spec.extraAllowlist ?? []).map(extensionName),
|
|
294
|
+
];
|
|
295
|
+
for (const name of names) {
|
|
296
|
+
const value = spec.parentEnv[name];
|
|
297
|
+
if (value !== undefined) {
|
|
298
|
+
env[name] = value;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for (const redirection of CREDENTIAL_STORE_REDIRECTIONS) {
|
|
302
|
+
const target = join(spec.scrubDir, redirection.relativePath);
|
|
303
|
+
if (redirection.kind === "directory") {
|
|
304
|
+
// Re-empty the directory target on every build (remove then
|
|
305
|
+
// recreate), exactly as the file targets are rewritten empty below,
|
|
306
|
+
// so a leftover store file from an earlier incarnation cannot
|
|
307
|
+
// smuggle configuration in (review finding O1).
|
|
308
|
+
const cleared = runStep(`re-emptying the redirect target ${target}`, () => {
|
|
309
|
+
rmSync(target, { recursive: true, force: true });
|
|
310
|
+
mkdirSync(target, { recursive: true });
|
|
311
|
+
});
|
|
312
|
+
if (!cleared.ok) {
|
|
313
|
+
return { ok: false, reason: cleared.reason };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
// The file target is written EMPTY on every build, so a leftover
|
|
318
|
+
// from an earlier incarnation cannot smuggle configuration in.
|
|
319
|
+
const refusal = refuseOpenForWrite(target);
|
|
320
|
+
if (refusal !== undefined) {
|
|
321
|
+
return { ok: false, reason: refusal };
|
|
322
|
+
}
|
|
323
|
+
const written = runStep(`staging the empty redirect target ${target}`, () => writeFileSync(target, ""));
|
|
324
|
+
if (!written.ok) {
|
|
325
|
+
return { ok: false, reason: written.reason };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
env[redirection.name] = target;
|
|
329
|
+
}
|
|
330
|
+
// Apple Git reads a prefix system config in addition to GIT_CONFIG_SYSTEM.
|
|
331
|
+
// This fixed kernel value is applied after copying and cannot be inherited.
|
|
332
|
+
env["GIT_CONFIG_NOSYSTEM"] = "1";
|
|
333
|
+
return { ok: true, env };
|
|
334
|
+
}
|