@b00dea/cockpit 0.1.0 → 0.1.1

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 (196) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +3 -0
  3. package/cockpit.schema.json +73 -3
  4. package/dist/autonomy.d.ts +3 -2
  5. package/dist/autonomy.js +30 -13
  6. package/dist/browser/commands.d.ts +57 -2
  7. package/dist/browser/commands.js +114 -10
  8. package/dist/browser/runtime.d.ts +1 -0
  9. package/dist/browser/runtime.js +4 -1
  10. package/dist/browser/surfaces.d.ts +7 -7
  11. package/dist/browser/surfaces.js +5 -0
  12. package/dist/canonical-json.d.ts +2 -0
  13. package/dist/canonical-json.js +5 -0
  14. package/dist/cli-main.d.ts +40 -3
  15. package/dist/cli-main.js +241 -62
  16. package/dist/command-table.d.ts +19 -11
  17. package/dist/command-table.js +190 -38
  18. package/dist/config/load.d.ts +16 -4
  19. package/dist/config/load.js +25 -3
  20. package/dist/config/unconfirmed-toml.d.ts +9 -0
  21. package/dist/config/unconfirmed-toml.js +29 -0
  22. package/dist/diagnostics.js +10 -1
  23. package/dist/docs/check.d.ts +16 -0
  24. package/dist/docs/check.js +197 -0
  25. package/dist/docs/decision-record.d.ts +34 -0
  26. package/dist/docs/decision-record.js +135 -0
  27. package/dist/docs/decisions.d.ts +16 -0
  28. package/dist/docs/decisions.js +103 -0
  29. package/dist/docs/markdown.d.ts +36 -0
  30. package/dist/docs/markdown.js +88 -0
  31. package/dist/docs/references.d.ts +32 -0
  32. package/dist/docs/references.js +148 -0
  33. package/dist/environment/application/confirm.d.ts +35 -0
  34. package/dist/environment/application/confirm.js +122 -0
  35. package/dist/environment/application/destructive.js +6 -2
  36. package/dist/environment/application/doctor.d.ts +12 -7
  37. package/dist/environment/application/doctor.js +20 -8
  38. package/dist/environment/application/guard.d.ts +12 -6
  39. package/dist/environment/application/guard.js +87 -32
  40. package/dist/environment/application/health.d.ts +14 -5
  41. package/dist/environment/application/health.js +8 -2
  42. package/dist/environment/application/lifecycle-context.d.ts +10 -3
  43. package/dist/environment/application/lifecycle-state.js +4 -3
  44. package/dist/environment/application/lifecycle.d.ts +2 -1
  45. package/dist/environment/application/lifecycle.js +11 -3
  46. package/dist/environment/application/services.d.ts +60 -0
  47. package/dist/environment/application/services.js +89 -0
  48. package/dist/environment/application/siblings.d.ts +43 -0
  49. package/dist/environment/application/siblings.js +269 -0
  50. package/dist/environment/claims.d.ts +10 -10
  51. package/dist/environment/claims.js +3 -11
  52. package/dist/environment/compose-topology.d.ts +15 -7
  53. package/dist/environment/compose-topology.js +100 -23
  54. package/dist/environment/contract.d.ts +42 -9
  55. package/dist/environment/contract.js +33 -0
  56. package/dist/environment/executable.d.ts +12 -0
  57. package/dist/environment/executable.js +46 -23
  58. package/dist/environment/generated-application/check.js +2 -0
  59. package/dist/environment/generated-application/prepare.js +2 -0
  60. package/dist/environment/generated-application/shared.d.ts +10 -2
  61. package/dist/environment/generated-application/shared.js +6 -1
  62. package/dist/environment/generated-application/surface-outputs.js +23 -12
  63. package/dist/environment/generated-application/surfaces.js +16 -11
  64. package/dist/environment/generated-application/sync.js +19 -4
  65. package/dist/environment/git-observation.d.ts +25 -6
  66. package/dist/environment/git-observation.js +37 -28
  67. package/dist/environment/health-composition.d.ts +3 -2
  68. package/dist/environment/health-composition.js +19 -2
  69. package/dist/environment/init-application.d.ts +39 -14
  70. package/dist/environment/init-application.js +39 -10
  71. package/dist/environment/lifecycle-composition.d.ts +4 -2
  72. package/dist/environment/lifecycle-composition.js +84 -17
  73. package/dist/environment/lifecycle-stage.d.ts +8 -3
  74. package/dist/environment/lifecycle-stage.js +11 -1
  75. package/dist/environment/logs.d.ts +1 -1
  76. package/dist/environment/map-limit.d.ts +2 -0
  77. package/dist/environment/map-limit.js +13 -0
  78. package/dist/environment/mcp-observation.d.ts +9 -0
  79. package/dist/environment/mcp-observation.js +46 -0
  80. package/dist/environment/observations.d.ts +1 -1
  81. package/dist/environment/observations.js +2 -0
  82. package/dist/environment/operator-application.d.ts +35 -6
  83. package/dist/environment/operator-application.js +42 -0
  84. package/dist/environment/presence.d.ts +4 -0
  85. package/dist/environment/presence.js +26 -0
  86. package/dist/environment/read-composition.d.ts +8 -3
  87. package/dist/environment/read-composition.js +53 -8
  88. package/dist/environment/readiness-composition.d.ts +20 -1
  89. package/dist/environment/readiness-composition.js +23 -2
  90. package/dist/environment/reclaim.d.ts +56 -19
  91. package/dist/environment/reclaim.js +147 -42
  92. package/dist/environment/runtime.d.ts +64 -18
  93. package/dist/environment/runtime.js +73 -3
  94. package/dist/environment/served-checkout-grants.d.ts +53 -0
  95. package/dist/environment/served-checkout-grants.js +182 -0
  96. package/dist/environment/sibling-checkouts.d.ts +119 -0
  97. package/dist/environment/sibling-checkouts.js +254 -0
  98. package/dist/environment/sibling-observation.d.ts +27 -0
  99. package/dist/environment/sibling-observation.js +45 -0
  100. package/dist/environment/sibling-pull.d.ts +23 -0
  101. package/dist/environment/sibling-pull.js +95 -0
  102. package/dist/environment/stack-inputs.d.ts +18 -0
  103. package/dist/environment/stack-inputs.js +210 -0
  104. package/dist/environment/stack-inventory-prune.d.ts +31 -0
  105. package/dist/environment/stack-inventory-prune.js +129 -0
  106. package/dist/environment/stack-inventory-report.d.ts +44 -0
  107. package/dist/environment/stack-inventory-report.js +78 -0
  108. package/dist/environment/stack-inventory.d.ts +94 -0
  109. package/dist/environment/stack-inventory.js +418 -0
  110. package/dist/environment/state.d.ts +48 -9
  111. package/dist/environment/state.js +142 -2
  112. package/dist/environment/tool-registration.js +3 -7
  113. package/dist/environment-exec/execute.d.ts +6 -3
  114. package/dist/environment-exec/execute.js +4 -0
  115. package/dist/fix-catalogue.d.ts +1 -1
  116. package/dist/generated-environment/adopt.js +4 -8
  117. package/dist/guards/answer-confirmation.d.ts +41 -0
  118. package/dist/guards/answer-confirmation.js +109 -0
  119. package/dist/guards/evaluator.d.ts +20 -3
  120. package/dist/guards/evaluator.js +39 -7
  121. package/dist/guards/local-identity.d.ts +6 -2
  122. package/dist/guards/local-identity.js +10 -4
  123. package/dist/guards/policy-change.d.ts +43 -0
  124. package/dist/guards/policy-change.js +159 -0
  125. package/dist/guards/policy-pin.d.ts +1 -1
  126. package/dist/guards/policy-pin.js +14 -12
  127. package/dist/init/command.d.ts +4 -3
  128. package/dist/init/command.js +36 -16
  129. package/dist/init/derive.d.ts +8 -6
  130. package/dist/init/derive.js +140 -36
  131. package/dist/init/draft.d.ts +13 -11
  132. package/dist/init/draft.js +54 -31
  133. package/dist/init/render.d.ts +18 -6
  134. package/dist/init/render.js +31 -9
  135. package/dist/repository-path.d.ts +9 -0
  136. package/dist/repository-path.js +16 -0
  137. package/dist/schema.d.ts +42 -75
  138. package/dist/schema.js +62 -11
  139. package/dist/surfaces/harness-registry.d.ts +80 -0
  140. package/dist/surfaces/harness-registry.js +29 -0
  141. package/dist/surfaces/mcp.d.ts +67 -17
  142. package/dist/surfaces/mcp.js +194 -31
  143. package/dist/surfaces/projection.d.ts +7 -7
  144. package/dist/surfaces/projection.js +41 -6
  145. package/dist/surfaces/render.d.ts +5 -44
  146. package/dist/surfaces/render.js +5 -2
  147. package/dist/team-metadata.js +2 -1
  148. package/dist/testing/test-protection.d.ts +34 -0
  149. package/dist/testing/test-protection.js +137 -0
  150. package/dist/testing/weakening.d.ts +15 -0
  151. package/dist/testing/weakening.js +30 -0
  152. package/dist/work-orders/application-closure.d.ts +7 -0
  153. package/dist/work-orders/application-closure.js +76 -0
  154. package/dist/work-orders/application-context.d.ts +162 -0
  155. package/dist/work-orders/application-context.js +137 -0
  156. package/dist/work-orders/application-decisions.d.ts +27 -0
  157. package/dist/work-orders/application-decisions.js +121 -0
  158. package/dist/work-orders/application-grading.d.ts +11 -0
  159. package/dist/work-orders/application-grading.js +212 -0
  160. package/dist/work-orders/application.d.ts +4 -97
  161. package/dist/work-orders/application.js +94 -254
  162. package/dist/work-orders/composition.d.ts +35 -58
  163. package/dist/work-orders/composition.js +216 -24
  164. package/dist/work-orders/contract.d.ts +22 -6
  165. package/dist/work-orders/contract.js +34 -16
  166. package/dist/work-orders/decisions.d.ts +23 -0
  167. package/dist/work-orders/decisions.js +55 -0
  168. package/dist/work-orders/store.d.ts +54 -5
  169. package/dist/work-orders/store.js +118 -24
  170. package/docs/README.md +6 -3
  171. package/docs/capability-matrix.md +17 -9
  172. package/docs/cli-reference.md +209 -1
  173. package/docs/command-list.md +12 -1
  174. package/docs/concepts.md +6 -4
  175. package/docs/configuration-essentials.md +7 -2
  176. package/docs/configuration-reference.md +18 -3
  177. package/docs/docs-check.md +76 -0
  178. package/docs/environment.md +194 -6
  179. package/docs/faq.md +5 -3
  180. package/docs/getting-started.md +109 -25
  181. package/docs/harness-surfaces.md +69 -2
  182. package/docs/safety-and-autonomy.md +56 -12
  183. package/docs/troubleshooting.md +29 -8
  184. package/docs/tutorial.md +3 -2
  185. package/docs/work-and-browser.md +180 -14
  186. package/libexec/browser-operation.cjs +86 -2
  187. package/package.json +2 -2
  188. package/surfaces/skills/cockpit-browser.skill.md +7 -2
  189. package/surfaces/skills/cockpit-explain.skill.md +1 -1
  190. package/surfaces/skills/cockpit-fix.skill.md +1 -1
  191. package/surfaces/skills/cockpit-setup.skill.md +3 -3
  192. package/surfaces/skills/cockpit-work.skill.md +1 -1
  193. package/surfaces/skills/procedures/explain.md +9 -4
  194. package/surfaces/skills/procedures/fix.md +25 -11
  195. package/surfaces/skills/procedures/setup.md +113 -40
  196. package/surfaces/skills/procedures/work.md +45 -16
@@ -1,4 +1,4 @@
1
- import { access, link, lstat, mkdir, open, opendir, readdir, realpath, rename, rmdir, unlink } from 'node:fs/promises';
1
+ import { access, link, lstat, mkdir, open, opendir, readdir, realpath, rename, rm, rmdir, unlink } from 'node:fs/promises';
2
2
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, renameSync } from 'node:fs';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
4
  import { setTimeout as delay } from 'node:timers/promises';
@@ -11,6 +11,7 @@ import { ANSWER_PROVENANCES } from '../init/render.js';
11
11
  import { SURFACE_TARGETS } from '../surfaces/contract.js';
12
12
  import { checkoutId as deriveCheckoutId } from './identity.js';
13
13
  import { redactSensitiveText } from './sensitive.js';
14
+ import { sha256Hex } from '../sha256.js';
14
15
  import { hasErrorCode, ENVIRONMENT_CODES, errorCause, RefusalError, snapshotEnvironmentObservation, snapshotJson, isCanonicalTimestamp, } from './contract.js';
15
16
  const OWNER = 'environment-state';
16
17
  export const observationDeclarations = Object.freeze([
@@ -418,7 +419,7 @@ export function authenticateStateNamespace(value) {
418
419
  ? value
419
420
  : undefined;
420
421
  }
421
- export function authenticateBootstrapStateNamespace(value) {
422
+ function authenticateBootstrapStateNamespace(value) {
422
423
  return value !== null && typeof value === 'object' && issuedBootstrapNamespaces.has(value)
423
424
  ? value
424
425
  : undefined;
@@ -932,6 +933,10 @@ export async function readState(address) {
932
933
  return { kind: 'missing', path: stableAddress.path };
933
934
  return malformed(stableAddress, error);
934
935
  }
936
+ return decodeStateRecord(stableAddress, bytes);
937
+ }
938
+ /** A record's bytes as read from an address that has already been checked safe, validated against that address. */
939
+ function decodeStateRecord(stableAddress, bytes) {
935
940
  let parsed;
936
941
  try {
937
942
  const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
@@ -2011,3 +2016,138 @@ export async function transactState(namespace, address, options, update) {
2011
2016
  throw operationFailure;
2012
2017
  return issueStatePublication(result, stableAddress);
2013
2018
  }
2019
+ function stackStatePaths(value, stackId) {
2020
+ return [
2021
+ { kind: 'coordination', path: value.paths.coordination(stackId).path },
2022
+ { kind: 'lifecycle', path: value.paths.lifecycle(stackId).path },
2023
+ { kind: 'lifecycle-history', path: lifecycleHistoryAddress(value, stackId).path },
2024
+ { kind: 'data-outcome', path: value.paths.dataOutcome(stackId).path },
2025
+ ];
2026
+ }
2027
+ async function readStackStateFiles(value, stackId) {
2028
+ const files = [];
2029
+ let coordination;
2030
+ for (const { kind, path } of stackStatePaths(value, stackId)) {
2031
+ let bytes;
2032
+ // Bytes, not parsed records: a record this version cannot parse is still this stack's and is
2033
+ // bound by its digest like any other.
2034
+ try {
2035
+ bytes = await readRegularFileNoFollow(path, () => ownedPathIsSafe(value.root, path, 'file'));
2036
+ }
2037
+ catch (error) {
2038
+ if (hasErrorCode(error, 'ENOENT'))
2039
+ continue;
2040
+ throw error;
2041
+ }
2042
+ files.push({ kind, path, sha256: sha256Hex(bytes) });
2043
+ if (kind === 'coordination')
2044
+ coordination = decodeStateRecord(value.paths.coordination(stackId), bytes);
2045
+ }
2046
+ return { files, coordination };
2047
+ }
2048
+ /** The record files a stack has under one cockpit's namespace, read as bytes so an unparseable record is listed too. */
2049
+ export async function inspectStackState(value, stackId) {
2050
+ const read = await inspectStackRecords(value, stackId);
2051
+ return read.kind === 'complete' || read.kind === 'indeterminate' ? { ...read, value: read.value.files } : read;
2052
+ }
2053
+ /** `inspectStackState`, plus the coordination record decoded from the bytes it read rather than from a second read. */
2054
+ export async function inspectStackRecords(value, stackId) {
2055
+ const authenticated = authenticateStateNamespace(value);
2056
+ if (!authenticated || !stackIdentity.test(stackId))
2057
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'stack state inspection requires an issued namespace and a stack id', 'stack state');
2058
+ try {
2059
+ return { kind: 'complete', value: await readStackStateFiles(authenticated, stackId) };
2060
+ }
2061
+ catch (error) {
2062
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `stack state could not be read safely: ${errorCause(error)}`, authenticated.cockpitRoot);
2063
+ }
2064
+ }
2065
+ /**
2066
+ * Deletes a stack's record files, which releases its port lease, only when each file still holds the
2067
+ * exact bytes `expected` names and no other file has appeared. It runs under the root port lock, so
2068
+ * no `up` leases ports from a view that still counts the lease, and under the cockpit's own state
2069
+ * lock, so no transaction rewrites a record between the check and the removal.
2070
+ */
2071
+ export async function removeStackState(value, stackId, expected, options) {
2072
+ const authenticated = authenticateStateNamespace(value);
2073
+ if (!authenticated || !stackIdentity.test(stackId) || !holderIdentity.test(options.ownerId)) {
2074
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'stack state removal requires an issued namespace, a stack id and a holder', 'stack state');
2075
+ }
2076
+ const scope = { kind: 'state-scoped-lock' };
2077
+ issuedScopedLocks.set(scope, Object.freeze({ namespace: authenticated, path: authenticated.paths.stateLock }));
2078
+ const key = (files) => files.map(({ kind, path, sha256 }) => `${kind}\0${path}\0${sha256}`).sort().join('\n');
2079
+ try {
2080
+ const locked = await withStateRootPortLock(authenticated, { ownerId: options.ownerId, contentionTimeoutMs: 2_000 }, () => withStateScopedLock(authenticated, scope, { ownerId: options.ownerId, contentionTimeoutMs: 2_000 }, async () => {
2081
+ const { files: current } = await readStackStateFiles(authenticated, stackId);
2082
+ if (key(current) !== key(expected))
2083
+ return { kind: 'changed', reason: `the state of ${stackId} changed since it was previewed` };
2084
+ for (const { path } of current)
2085
+ await unlink(path);
2086
+ for (const directory of new Set(current.map(({ path }) => dirname(path))))
2087
+ await syncDirectory(directory);
2088
+ return { kind: 'removed', paths: current.map(({ path }) => path) };
2089
+ }));
2090
+ return locked.kind === 'complete' ? locked.value : locked;
2091
+ }
2092
+ catch (error) {
2093
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `stack state could not be removed safely: ${errorCause(error)}`, authenticated.cockpitRoot);
2094
+ }
2095
+ }
2096
+ const NAMESPACE_FINGERPRINT_ENTRIES = 4_096;
2097
+ async function namespaceFingerprint(path) {
2098
+ const hash = createHash('sha256');
2099
+ let entries = 0;
2100
+ const walk = async (directory) => {
2101
+ for (const name of (await readdir(directory)).sort()) {
2102
+ if (++entries > NAMESPACE_FINGERPRINT_ENTRIES)
2103
+ throw new RefusalError('state folder exceeds its entry boundary');
2104
+ const child = join(directory, name);
2105
+ const status = await lstat(child, { bigint: true });
2106
+ if (!privatelyOwned({ uid: Number(status.uid), mode: Number(status.mode) }))
2107
+ throw new RefusalError(`${child} is not privately owned`);
2108
+ hash.update(`${relative(path, child)}\0${status.mode}\0${status.size}\0${status.mtimeNs}\n`);
2109
+ if (status.isDirectory())
2110
+ await walk(child);
2111
+ }
2112
+ };
2113
+ const status = await lstat(path);
2114
+ if (status.isSymbolicLink() || !status.isDirectory() || !privatelyOwned(status))
2115
+ throw new RefusalError(`${path} is not a private directory`);
2116
+ await walk(path);
2117
+ return hash.digest('hex');
2118
+ }
2119
+ function otherNamespacePath(value, path) {
2120
+ return typeof path === 'string' && isAbsolute(path) && dirname(path) === join(value.root, 'cockpits')
2121
+ && path !== value.cockpitRoot && !basename(path).startsWith('.');
2122
+ }
2123
+ /** A digest of another cockpit's state folder that this version cannot read: every entry's path, mode, size and modification time. */
2124
+ export async function inspectStateNamespaceFolder(value, path) {
2125
+ const authenticated = authenticateStateNamespace(value);
2126
+ if (!authenticated || !otherNamespacePath(authenticated, path))
2127
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'only another cockpit\'s state folder can be inspected for removal', String(path));
2128
+ try {
2129
+ return { kind: 'complete', value: await namespaceFingerprint(path) };
2130
+ }
2131
+ catch (error) {
2132
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state folder could not be inspected: ${errorCause(error)}`, path);
2133
+ }
2134
+ }
2135
+ /** Deletes another cockpit's unreadable state folder, under the root port lock, only while it still matches `fingerprint`. */
2136
+ export async function removeStateNamespaceFolder(value, path, fingerprint, options) {
2137
+ const authenticated = authenticateStateNamespace(value);
2138
+ if (!authenticated || !otherNamespacePath(authenticated, path) || !digest.test(fingerprint) || !holderIdentity.test(options.ownerId)) {
2139
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, 'only another cockpit\'s previewed state folder can be removed', String(path));
2140
+ }
2141
+ try {
2142
+ return await withStateRootPortLock(authenticated, { ownerId: options.ownerId, contentionTimeoutMs: 2_000 }, async () => {
2143
+ if (await namespaceFingerprint(path) !== fingerprint)
2144
+ return { kind: 'changed', reason: `${path} changed since it was previewed` };
2145
+ await rm(path, { recursive: true });
2146
+ await syncDirectory(dirname(path));
2147
+ return { kind: 'removed', paths: [path] };
2148
+ });
2149
+ }
2150
+ catch (error) {
2151
+ return refusal(ENVIRONMENT_CODES.STATE_RECORD_MALFORMED, `state folder could not be removed safely: ${errorCause(error)}`, path);
2152
+ }
2153
+ }
@@ -8,6 +8,7 @@ import { createHash } from 'node:crypto';
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { parse, TomlError } from 'smol-toml';
10
10
  import { loadCockpitConfig } from '../config/load.js';
11
+ import { canonicalJson } from '../canonical-json.js';
11
12
  import { setSurfaceTools } from '../config/surface-tools-toml.js';
12
13
  import { writeVerifiedConfig } from '../config/verified-write.js';
13
14
  import { cockpitCommand } from '../fix-catalogue.js';
@@ -16,11 +17,6 @@ import { harnessFact, SURFACE_TARGETS } from '../surfaces/harness-registry.js';
16
17
  import { ENVIRONMENT_CODES, errorCause } from './contract.js';
17
18
  import { refusalOutcome as refused } from '../refusal.js';
18
19
  const EDIT_BY_HAND_FIX = 'edit the tools list under [surfaces] in cockpit.toml by hand, then run npx --no cockpit sync --config <your cockpit.toml> --dry-run';
19
- /** Key order must not matter when two parsed configurations are compared. */
20
- function canonical(value) {
21
- return JSON.stringify(value, (_key, item) => item !== null && typeof item === 'object' && !Array.isArray(item)
22
- ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : 1)) : item);
23
- }
24
20
  function done(artifact, result) {
25
21
  return { status: 0, diagnostics: [], destination: null, artifact, result };
26
22
  }
@@ -63,8 +59,8 @@ export async function runToolChange(input) {
63
59
  const issue = parsed.error.issues[0];
64
60
  return refused(ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, `${input.operation === 'add' ? 'adding' : 'removing'} ${tool} would leave cockpit.toml invalid (${issue ? `${issue.path.join('.') || '(root)'}: ${issue.message}` : 'schema'}); nothing was written`, 'surfaces.tools', 'change what the message names first, then run the command again');
65
61
  }
66
- const expected = canonical({ ...config, surfaces: { ...config.surfaces, tools: after } });
67
- const exact = (candidate) => canonical(candidate) === expected;
62
+ const expected = canonicalJson({ ...config, surfaces: { ...config.surfaces, tools: after } });
63
+ const exact = (candidate) => canonicalJson(candidate) === expected;
68
64
  if (!exact(parsed.data)) {
69
65
  return refused(ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, 'the edit would change more than the tools list; nothing was written', 'surfaces.tools', EDIT_BY_HAND_FIX);
70
66
  }
@@ -30,21 +30,21 @@ export type ForegroundProcessOutcome = Readonly<{
30
30
  started: boolean;
31
31
  terminationConfirmed: boolean;
32
32
  }>;
33
- export interface StepExecution {
33
+ interface StepExecution {
34
34
  id: string;
35
35
  exitCode: number;
36
36
  signal: NodeJS.Signals | null;
37
37
  stdoutBytes: number;
38
38
  stderrBytes: number;
39
39
  }
40
- export interface ProbeStepExecution extends StepExecution {
40
+ interface ProbeStepExecution extends StepExecution {
41
41
  started: boolean;
42
42
  stdoutObservedBytes: number;
43
43
  stderrObservedBytes: number;
44
44
  stdoutRetainedBytes: number;
45
45
  stderrRetainedBytes: number;
46
46
  }
47
- export interface CompensationFailure {
47
+ interface CompensationFailure {
48
48
  id: string;
49
49
  code: ExecutionFailureCode;
50
50
  }
@@ -91,6 +91,8 @@ export type ExecuteProbeOutcome = {
91
91
  /** The outcome when this executor produced it for exactly `step`; undefined for any other value. */
92
92
  export declare function authenticatedExecutionOutcome(outcome: unknown, step: ExecutionStep): ExecuteProbeOutcome | undefined;
93
93
  /** Runs one operator/agent session process while retaining ownership of its whole process group. */
94
+ /** Whether a foreground process inherits a terminal on both stdin and stdout, so it may ask for one of its own. */
95
+ export declare function foregroundTerminal(): boolean;
94
96
  export declare function executeForegroundProcess(input: ForegroundProcessInput, options?: Readonly<{
95
97
  signal?: AbortSignal;
96
98
  }>): Promise<ForegroundProcessOutcome>;
@@ -100,3 +102,4 @@ export declare function executeProbe(step: ExecutionStep, options?: {
100
102
  export declare function executePlan(plan: ExecutionPlan, options?: {
101
103
  signal?: AbortSignal;
102
104
  }): Promise<ExecutePlanOutcome>;
105
+ export {};
@@ -867,6 +867,10 @@ function captureForegroundInput(input) {
867
867
  }
868
868
  }
869
869
  /** Runs one operator/agent session process while retaining ownership of its whole process group. */
870
+ /** Whether a foreground process inherits a terminal on both stdin and stdout, so it may ask for one of its own. */
871
+ export function foregroundTerminal() {
872
+ return process.stdin.isTTY === true && process.stdout.isTTY === true;
873
+ }
870
874
  export async function executeForegroundProcess(input, options = {}) {
871
875
  const captured = captureForegroundInput(input);
872
876
  let signal;
@@ -1,5 +1,5 @@
1
1
  import type { Diagnostic } from './environment/contract.js';
2
- export type ConfiguredCommand = 'setup' | 'sync' | 'check' | 'selftest' | 'upgrade' | 'up' | 'sleep' | 'session' | 'down' | 'reset' | 'preflight' | 'doctor' | 'logs' | 'runtime measure' | 'sibling add' | 'sibling remove';
2
+ export type ConfiguredCommand = 'setup' | 'sync' | 'check' | 'confirm' | 'selftest' | 'upgrade' | 'up' | 'sleep' | 'session' | 'down' | 'reset' | 'preflight' | 'doctor' | 'logs' | 'runtime measure' | 'sibling add' | 'sibling remove' | 'stacks' | 'stacks prune' | 'docs check' | 'use' | 'pull';
3
3
  export interface FixContext {
4
4
  readonly resolvedConfigPath: string | null;
5
5
  readonly generatedTargets: readonly string[];
@@ -10,6 +10,7 @@
10
10
  * back, so a refused adoption does not silently leave the team's settings moved aside.
11
11
  */
12
12
  import { sha256Hex } from '../sha256.js';
13
+ import { canonicalJson } from '../canonical-json.js';
13
14
  import { basename, dirname, resolve } from 'node:path';
14
15
  import { parse, TomlError } from 'smol-toml';
15
16
  import { loadCockpitConfig, STRUCTURED_INPUT_BYTE_LIMIT } from '../config/load.js';
@@ -30,11 +31,6 @@ function tomlFailure(error) {
30
31
  return error instanceof TomlError ? `TOML error at line ${error.line}, column ${error.column}` : errorCause(error);
31
32
  }
32
33
  const ADOPT_BY_HAND_FIX = 'move the settings file to cockpit/adopted/<the same path>, add its [[surfaces.adopted]] row to cockpit.toml by hand with the fields the configuration reference lists, then run npx --no cockpit sync --config <your cockpit.toml>';
33
- /** Key order must not matter when two parsed configurations are compared. */
34
- function canonical(value) {
35
- return JSON.stringify(value, (_key, item) => item !== null && typeof item === 'object' && !Array.isArray(item)
36
- ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : 1)) : item);
37
- }
38
34
  function adoptionTable(row) {
39
35
  return [
40
36
  '[[surfaces.adopted]]',
@@ -160,7 +156,7 @@ export async function runSyncAdoption(input, synchronize, adapterOverrides = {})
160
156
  catch (error) {
161
157
  return refused(ENVIRONMENT_CODES.CONFIG_PARSE_INVALID, `recording the adoption would leave cockpit.toml unparseable (${tomlFailure(error)}); nothing was moved`, configPath, ADOPT_BY_HAND_FIX);
162
158
  }
163
- if (!candidateConfig.success || canonical(candidateConfig.data) !== canonical(expected)) {
159
+ if (!candidateConfig.success || canonicalJson(candidateConfig.data) !== canonicalJson(expected)) {
164
160
  return refused(ENVIRONMENT_CODES.CONFIG_SCHEMA_INVALID, 'recording the adoption would change more than the [[surfaces.adopted]] rows; nothing was moved', configPath, ADOPT_BY_HAND_FIX);
165
161
  }
166
162
  const moved = [];
@@ -187,7 +183,7 @@ export async function runSyncAdoption(input, synchronize, adapterOverrides = {})
187
183
  const restored = await writeVerifiedConfig({
188
184
  path: configPath, expectedSha256: sha256Hex(nextText), temporaryTag: 'adopt',
189
185
  edit: () => originalText,
190
- isExactChange: (value) => canonical(value) === canonical(config),
186
+ isExactChange: (value) => canonicalJson(value) === canonicalJson(config),
191
187
  messages: {
192
188
  changedBefore: { message: 'cockpit.toml changed after the adoption was recorded, so its [[surfaces.adopted]] rows were left in place', fix },
193
189
  changedDuring: { message: 'cockpit.toml changed while the adoption was being undone, so its [[surfaces.adopted]] rows were left in place', fix },
@@ -230,7 +226,7 @@ export async function runSyncAdoption(input, synchronize, adapterOverrides = {})
230
226
  written = await writeVerifiedConfig({
231
227
  path: configPath, expectedSha256: loaded.result.source.sha256, temporaryTag: 'adopt',
232
228
  edit: () => nextText,
233
- isExactChange: (value) => canonical(value) === canonical(expected),
229
+ isExactChange: (value) => canonicalJson(value) === canonicalJson(expected),
234
230
  messages: {
235
231
  changedBefore: { message: 'cockpit.toml changed since it was read', fix: `let the other edit finish, then have a human approve ${rerun} again` },
236
232
  changedDuring: { message: 'cockpit.toml changed while the adoption was being recorded', fix: `let the other edit finish, then have a human approve ${rerun} again` },
@@ -0,0 +1,41 @@
1
+ import { UNCONFIRMABLE_ANSWERS, type CockpitConfig } from '../schema.js';
2
+ export type UnconfirmedAnswerName = typeof UNCONFIRMABLE_ANSWERS[number];
3
+ export type ConfirmingObservation = 'preflight' | 'check' | 'runtime-floor';
4
+ export type AnswerConfirmation = Readonly<{
5
+ by: 'observation';
6
+ observation: ConfirmingObservation;
7
+ verifies: string;
8
+ }> | Readonly<{
9
+ by: 'person';
10
+ trustDecision: boolean;
11
+ why: string;
12
+ }>;
13
+ export declare const ANSWER_CONFIRMATIONS: Readonly<Record<UnconfirmedAnswerName, AnswerConfirmation>>;
14
+ export declare const ANSWER_CONFIRMATION_PATH: string;
15
+ export type AnswerConfirmationRecord = Readonly<{
16
+ observation: ConfirmingObservation;
17
+ ranAt: string;
18
+ result: 'passed';
19
+ evidenceSha256: string;
20
+ configurationSha256: string;
21
+ }>;
22
+ type Records = Readonly<Partial<Record<UnconfirmedAnswerName, AnswerConfirmationRecord>>>;
23
+ /**
24
+ * The digest a record binds to: the whole configuration except the `unconfirmed` list, so confirming
25
+ * one answer leaves the records of the others current, and any other edit leaves every record stale.
26
+ */
27
+ export declare function confirmationConfigurationSha256(config: CockpitConfig): string;
28
+ /** Every well-formed record the bytes hold, or none when they are absent or not a current-format file. */
29
+ export declare function parseAnswerConfirmations(bytes: Uint8Array | undefined): Records;
30
+ export declare function encodeAnswerConfirmations(records: Records): string;
31
+ /**
32
+ * The answers whose recorded observation passed against exactly `config`. A missing, malformed or
33
+ * stale record confirms nothing, and no record confirms an answer only a person may.
34
+ */
35
+ export declare function confirmedAnswers(bytes: Uint8Array | undefined, config: CockpitConfig): ReadonlySet<string>;
36
+ export declare function isUnconfirmedAnswerName(value: string): value is UnconfirmedAnswerName;
37
+ /** The confirmation a person makes, for the answers only a person may confirm. */
38
+ export declare function personConfirmationFix(answers: readonly string[], configPath?: string): string;
39
+ /** How each listed answer is confirmed: the observation an agent runs, or the person who must. */
40
+ export declare function unconfirmedFix(answers: readonly UnconfirmedAnswerName[]): string;
41
+ export {};
@@ -0,0 +1,109 @@
1
+ // Owns who may confirm each init answer listed under `unconfirmed`, and the record of the
2
+ // observations that confirmed one. A person keeps every answer that is a trust decision and every
3
+ // answer no observation settles exactly; an agent may confirm the rest with `cockpit confirm`, which
4
+ // runs the observation and records it here. The guard then lets the agent's own sync apply the
5
+ // removed entry, but only while the record matches the configuration on disk.
6
+ //
7
+ // The record is machine-local, under `.cockpit/local`: the observation is evidence about this machine
8
+ // and its running stack, which another clone has not seen, so there a removed entry still waits for a
9
+ // person. The directory is a guard control directory, so no agent tool call may write it; only the
10
+ // cockpit process that ran the observation does.
11
+ import { canonicalJson } from '../canonical-json.js';
12
+ import { exactKeys } from '../exact-keys.js';
13
+ import { cockpitCommand } from '../fix-catalogue.js';
14
+ import { UNCONFIRMABLE_ANSWERS } from '../schema.js';
15
+ import { sha256Hex } from '../sha256.js';
16
+ import { GUARD_LOCAL_PATHS } from './local-identity.js';
17
+ export const ANSWER_CONFIRMATIONS = Object.freeze({
18
+ 'ports.bases': { by: 'observation', observation: 'preflight',
19
+ verifies: 'preflight passes with the stack up, its port family leased on the declared bases and every listener and health observation passing' },
20
+ 'environment.compose_files': { by: 'observation', observation: 'check',
21
+ verifies: 'check passes, which parses the declared Compose files with docker compose config and resolves the stack\'s topology from them' },
22
+ 'environment.machine': { by: 'observation', observation: 'runtime-floor',
23
+ verifies: 'the container runtime measured on this machine meets the declared memory, CPU and free-disk floor' },
24
+ guards: { by: 'person', trustDecision: true, why: 'it sets which commands and hosts the guard denies' },
25
+ testing: { by: 'person', trustDecision: true, why: 'it decides which tests grade the agent\'s own work' },
26
+ 'environment.install_policies': { by: 'person', trustDecision: true, why: 'it sets the build and test commands the guard admits' },
27
+ sibling: { by: 'person', trustDecision: true, why: 'it sets each repository\'s purpose and the roots agents may write' },
28
+ 'environment.posture': { by: 'person', trustDecision: true, why: 'it decides browser preapproval and what data agents are exposed to' },
29
+ 'cockpit.name': { by: 'person', trustDecision: false,
30
+ why: 'a name is a choice, and no observation shows it is the one the owner wants; it scopes Compose projects, state and generated files' },
31
+ 'estate.runnable': { by: 'person', trustDecision: false,
32
+ why: 'a stack that starts shows the declared services run, not that a repository declared not runnable should stay out' },
33
+ 'environment.test_data': { by: 'person', trustDecision: false,
34
+ why: 'it states what local work needs (seeded data, signed-in users), and no observation shows a need is absent' },
35
+ });
36
+ export const ANSWER_CONFIRMATION_PATH = `${GUARD_LOCAL_PATHS.directory}/answer-confirmations`;
37
+ const FORMAT = 'cockpit-answer-confirmations/v1';
38
+ const RECORD_KEYS = ['observation', 'ranAt', 'result', 'evidenceSha256', 'configurationSha256'];
39
+ const SHA256 = /^[a-f0-9]{64}$/;
40
+ const TIMESTAMP = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/;
41
+ /**
42
+ * The digest a record binds to: the whole configuration except the `unconfirmed` list, so confirming
43
+ * one answer leaves the records of the others current, and any other edit leaves every record stale.
44
+ */
45
+ export function confirmationConfigurationSha256(config) {
46
+ const { unconfirmed: _unconfirmed, ...observed } = config;
47
+ return sha256Hex(canonicalJson(observed));
48
+ }
49
+ function validRecord(answer, value) {
50
+ const confirmation = ANSWER_CONFIRMATIONS[answer];
51
+ if (!confirmation || confirmation.by !== 'observation' || value === null || typeof value !== 'object' || Array.isArray(value))
52
+ return false;
53
+ const row = value;
54
+ return exactKeys(row, RECORD_KEYS) && row.observation === confirmation.observation && row.result === 'passed'
55
+ && typeof row.ranAt === 'string' && TIMESTAMP.test(row.ranAt) && Number.isFinite(Date.parse(row.ranAt))
56
+ && typeof row.evidenceSha256 === 'string' && SHA256.test(row.evidenceSha256)
57
+ && typeof row.configurationSha256 === 'string' && SHA256.test(row.configurationSha256);
58
+ }
59
+ /** Every well-formed record the bytes hold, or none when they are absent or not a current-format file. */
60
+ export function parseAnswerConfirmations(bytes) {
61
+ if (!bytes)
62
+ return {};
63
+ let value;
64
+ try {
65
+ value = JSON.parse(Buffer.from(bytes).toString('utf8'));
66
+ }
67
+ catch (error) {
68
+ if (error instanceof SyntaxError)
69
+ return {};
70
+ throw error;
71
+ }
72
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
73
+ return {};
74
+ const file = value;
75
+ const confirmations = file.confirmations;
76
+ if (!exactKeys(file, ['format', 'confirmations']) || file.format !== FORMAT
77
+ || confirmations === null || typeof confirmations !== 'object' || Array.isArray(confirmations))
78
+ return {};
79
+ return Object.fromEntries(Object.entries(confirmations).filter(([answer, row]) => validRecord(answer, row)));
80
+ }
81
+ export function encodeAnswerConfirmations(records) {
82
+ return `${JSON.stringify({ format: FORMAT, confirmations: records }, null, 2)}\n`;
83
+ }
84
+ /**
85
+ * The answers whose recorded observation passed against exactly `config`. A missing, malformed or
86
+ * stale record confirms nothing, and no record confirms an answer only a person may.
87
+ */
88
+ export function confirmedAnswers(bytes, config) {
89
+ const digest = confirmationConfigurationSha256(config);
90
+ return new Set(Object.entries(parseAnswerConfirmations(bytes))
91
+ .filter(([, row]) => row.configurationSha256 === digest).map(([answer]) => answer));
92
+ }
93
+ export function isUnconfirmedAnswerName(value) {
94
+ return UNCONFIRMABLE_ANSWERS.includes(value);
95
+ }
96
+ /** The confirmation a person makes, for the answers only a person may confirm. */
97
+ export function personConfirmationFix(answers, configPath) {
98
+ return `a person confirms ${answers.join(', ')} with whoever owns the product, corrects cockpit.toml if a value is wrong, deletes ${answers.length === 1 ? 'its entry' : 'each entry'} from unconfirmed, then runs ${cockpitCommand('sync', configPath === undefined ? {} : { config: configPath })} in a terminal outside the agent`;
99
+ }
100
+ /** How each listed answer is confirmed: the observation an agent runs, or the person who must. */
101
+ export function unconfirmedFix(answers) {
102
+ const observed = answers.flatMap(answer => {
103
+ const confirmation = ANSWER_CONFIRMATIONS[answer];
104
+ return confirmation.by === 'observation'
105
+ ? [`run ${cockpitCommand('confirm', { operand: answer })}, which confirms ${answer} only if ${confirmation.verifies}`] : [];
106
+ });
107
+ const person = answers.filter(answer => ANSWER_CONFIRMATIONS[answer].by === 'person');
108
+ return [...observed, ...person.length ? [personConfirmationFix(person)] : []].join('; ');
109
+ }
@@ -1,3 +1,19 @@
1
+ /**
2
+ * The cockpit commands that run the words after their `--` as a command of their own, and where it
3
+ * runs: a session's on the host, exec's inside a product container that mounts the checkouts and
4
+ * may hold their credentials. The guard judges that command as an invocation of its own, so
5
+ * wrapping a command never admits more than running it directly.
6
+ */
7
+ export declare const WRAPPING_COMMANDS: ReadonlyMap<string, 'host' | 'container'>;
8
+ /**
9
+ * The command a wrapping cockpit command runs, from its arguments after the launcher: the words
10
+ * after its first `--`, and where they run. Undefined when the command wraps none or names no
11
+ * command after `--`.
12
+ */
13
+ export declare function innerCommand(argv: readonly string[]): Readonly<{
14
+ words: readonly string[];
15
+ runs: 'host' | 'container';
16
+ }> | undefined;
1
17
  export interface GuardInvocationProfile {
2
18
  readonly executable: string;
3
19
  readonly cwd: string;
@@ -17,12 +33,12 @@ export interface GuardProtectedIdentity {
17
33
  readonly identity: string | null;
18
34
  readonly ancestors: readonly string[];
19
35
  }
20
- export interface GuardDeclaredAllow {
36
+ interface GuardDeclaredAllow {
21
37
  readonly argv: readonly string[];
22
38
  readonly reason: string;
23
39
  readonly cwd?: string;
24
40
  }
25
- export interface GuardDeclaredDeny {
41
+ interface GuardDeclaredDeny {
26
42
  readonly match: 'prefix' | 'contains';
27
43
  readonly argv: readonly string[];
28
44
  readonly reason: string;
@@ -68,7 +84,7 @@ export interface GuardEvaluationPolicy {
68
84
  readonly cockpitLaunchers: readonly GuardInvocationProfile[];
69
85
  readonly projectCommands: readonly GuardProjectCommand[];
70
86
  }
71
- export interface PathInspectionRequest {
87
+ interface PathInspectionRequest {
72
88
  readonly path: string;
73
89
  readonly cwd: string;
74
90
  /**
@@ -111,3 +127,4 @@ export declare function guardIdentity(status: {
111
127
  export declare function foldGuardSpelling(path: string): string;
112
128
  /** Hard boundaries are independent of grants and are evaluated across the complete capture. */
113
129
  export declare function evaluateGuard(input: unknown, policy: unknown, inspect: PathInspection): GuardDecision;
130
+ export {};
@@ -7,6 +7,25 @@ import { renderGuardProjectCommand } from '../environment/install-policy.js';
7
7
  import { GUARD_LOCAL_PATHS } from './local-identity.js';
8
8
  import { classifyReadOnlyInvocation, cockpitLauncherPrefix } from './read-only.js';
9
9
  import { SURFACE_EXECUTION_CONTROL_PATHS } from '../surfaces/harness-registry.js';
10
+ /**
11
+ * The cockpit commands that run the words after their `--` as a command of their own, and where it
12
+ * runs: a session's on the host, exec's inside a product container that mounts the checkouts and
13
+ * may hold their credentials. The guard judges that command as an invocation of its own, so
14
+ * wrapping a command never admits more than running it directly.
15
+ */
16
+ export const WRAPPING_COMMANDS = new Map([['session', 'host'], ['exec', 'container']]);
17
+ /**
18
+ * The command a wrapping cockpit command runs, from its arguments after the launcher: the words
19
+ * after its first `--`, and where they run. Undefined when the command wraps none or names no
20
+ * command after `--`.
21
+ */
22
+ export function innerCommand(argv) {
23
+ const runs = argv.length ? WRAPPING_COMMANDS.get(argv[0]) : undefined;
24
+ if (!runs)
25
+ return undefined;
26
+ const separator = argv.indexOf('--');
27
+ return separator < 0 || separator === argv.length - 1 ? undefined : { words: argv.slice(separator + 1), runs };
28
+ }
10
29
  // An agent able to rewrite any of these can switch the guard off, grant itself commands or run code
11
30
  // when a harness starts: the published guard transport, every harness registration that launches it,
12
31
  // the manifest that authenticates both, Claude's local settings, where `disableAllHooks` would switch
@@ -1029,16 +1048,23 @@ export function evaluateGuard(input, policy, inspect) {
1029
1048
  holdAsk(invocation, ask(UNPROVEN_LAUNCHER));
1030
1049
  return { operands: invocation.argv.slice(prefix) };
1031
1050
  };
1032
- // A session runs the words after `--` as a command, with no shell, in its own directory, so
1033
- // that command is judged as one more invocation: the session never admits more than it would.
1051
+ // A session runs the words after `--` as a command, with no shell, in its own directory, and exec
1052
+ // runs them in a product container that mounts the checkouts and may hold their credentials, so
1053
+ // that command is judged as one more invocation: neither admits more than the guard would. The
1054
+ // name-based denials apply to exec's command as to any other, but a read-only proof is about the
1055
+ // host's files and exec's command sees the container's, so only a declared allow rule or a
1056
+ // build or test command admits it.
1057
+ const containerCommands = new Set();
1034
1058
  const invocations = input.invocations.flatMap((invocation) => {
1035
1059
  const cockpit = provenCockpit(invocation);
1036
- const separator = cockpit?.[0] === 'session' ? cockpit.indexOf('--') : -1;
1037
- const command = separator < 0 ? [] : cockpit.slice(separator + 1);
1038
- if (!command.length)
1060
+ const command = cockpit && innerCommand(cockpit);
1061
+ if (!command)
1039
1062
  return [invocation];
1040
- return [invocation, { executable: command[0], argv: command.slice(1), cwd: invocation.cwd, wrapped: false, redirects: [],
1041
- words: command.map((value, index) => ({ role: index ? 'argument' : 'executable', value })) }];
1063
+ const inner = { executable: command.words[0], argv: command.words.slice(1), cwd: invocation.cwd, wrapped: false, redirects: [],
1064
+ words: command.words.map((value, index) => ({ role: index ? 'argument' : 'executable', value })) };
1065
+ if (command.runs === 'container')
1066
+ containerCommands.add(inner);
1067
+ return [invocation, inner];
1042
1068
  });
1043
1069
  const accesses = new Map(invocations.map((invocation) => {
1044
1070
  const cockpit = provenCockpit(invocation);
@@ -1215,6 +1241,12 @@ export function evaluateGuard(input, policy, inspect) {
1215
1241
  continue;
1216
1242
  const matches = (profile, cwd) => profile.executable === invocation.executable
1217
1243
  && cwd === invocation.cwd && profile.argv.length === invocation.argv.length && profile.argv.every((word, i) => word === invocation.argv[i]);
1244
+ if (containerCommands.has(invocation)) {
1245
+ if (resolved.projectCommands.some((profile) => matches(profile, profile.checkoutRoot)))
1246
+ continue;
1247
+ return ask(`${named(invocation)} runs inside the product container, whose filesystem the guard cannot see, so no read-only proof about host paths covers it. `
1248
+ + `The guard admits it only as a declared build or test command or under a [guards] allow rule; a person approves it or runs it in a terminal, or admits it for good under [guards] allow in ${posix.basename(resolved.configPath)}.`);
1249
+ }
1218
1250
  if (!readOnly.has(invocation) && !resolved.cockpitInvocations.some((profile) => matches(profile, profile.cwd))
1219
1251
  && !resolved.projectCommands.some((profile) => matches(profile, profile.checkoutRoot))) {
1220
1252
  const config = /^[\w./@%+=:,-]+$/.test(resolved.configPath) ? resolved.configPath : `'${resolved.configPath.replaceAll("'", "'\\''")}'`;
@@ -16,13 +16,17 @@ export interface GuardHandlerIdentity {
16
16
  export declare const GUARD_LOCAL_PATHS: Readonly<{
17
17
  readonly directory: '.cockpit/local';
18
18
  readonly identity: '.cockpit/local/guard-identity';
19
+ readonly generatedConfig: '.cockpit/local/guard-config';
19
20
  readonly ignore: '.cockpit/local/.gitignore';
20
21
  }>;
21
22
  export declare function encodeGuardLocalIdentity(identity: GuardHandlerIdentity): string;
22
23
  /** The exact identity the bytes carry, or undefined when they are not a complete current-format file. */
23
24
  export declare function parseGuardLocalIdentity(bytes: string): GuardHandlerIdentity | undefined;
24
- /** Publish the identity after the tracked surfaces it authenticates; the reason names what failed. */
25
- export declare function writeGuardLocalIdentity(root: string, identity: GuardHandlerIdentity): Promise<{
25
+ /**
26
+ * Publish the identity after the tracked surfaces it authenticates, with the configuration text it
27
+ * names written first; the reason names what failed.
28
+ */
29
+ export declare function writeGuardLocalIdentity(root: string, identity: GuardHandlerIdentity, configText: string): Promise<{
26
30
  readonly kind: 'complete';
27
31
  } | {
28
32
  readonly kind: 'refusal';
@@ -6,12 +6,14 @@
6
6
  import { exactKeys } from '../exact-keys.js';
7
7
  import { randomBytes } from 'node:crypto';
8
8
  import { lstat, mkdir, rename, rm, writeFile } from 'node:fs/promises';
9
- import { join } from 'node:path';
9
+ import { basename, join } from 'node:path';
10
10
  import { readBoundedInput } from '../config/load.js';
11
11
  import { errorCause, hasErrorCode } from '../environment/contract.js';
12
12
  export const GUARD_LOCAL_PATHS = Object.freeze({
13
13
  directory: '.cockpit/local',
14
14
  identity: '.cockpit/local/guard-identity',
15
+ // The exact configuration the guards were generated from, so a later edit can be compared with it.
16
+ generatedConfig: '.cockpit/local/guard-config',
15
17
  ignore: '.cockpit/local/.gitignore',
16
18
  });
17
19
  // The guard script reads this file with `read` one line at a time, so the layout is lines rather
@@ -88,9 +90,13 @@ async function replaceFile(directory, name, bytes) {
88
90
  await rm(temporary, { force: true });
89
91
  }
90
92
  }
91
- /** Publish the identity after the tracked surfaces it authenticates; the reason names what failed. */
92
- export async function writeGuardLocalIdentity(root, identity) {
93
- return writeCockpitLocalFiles(root, [{ name: 'guard-identity', bytes: encodeGuardLocalIdentity(identity) }]);
93
+ /**
94
+ * Publish the identity after the tracked surfaces it authenticates, with the configuration text it
95
+ * names written first; the reason names what failed.
96
+ */
97
+ export async function writeGuardLocalIdentity(root, identity, configText) {
98
+ return writeCockpitLocalFiles(root, [{ name: basename(GUARD_LOCAL_PATHS.generatedConfig), bytes: configText },
99
+ { name: basename(GUARD_LOCAL_PATHS.identity), bytes: encodeGuardLocalIdentity(identity) }]);
94
100
  }
95
101
  /** Replace machine-local files under `.cockpit/local`, a directory that ignores itself; the reason names what failed. */
96
102
  export async function writeCockpitLocalFiles(root, files) {