@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
@@ -0,0 +1,43 @@
1
+ import type { EnvironmentDiagnostic } from '../environment/contract.js';
2
+ import type { CockpitConfig } from '../schema.js';
3
+ type PolicyChange = Readonly<{
4
+ kind: 'non-widening';
5
+ }> | Readonly<{
6
+ kind: 'widening';
7
+ fields: readonly string[];
8
+ }>;
9
+ /**
10
+ * Every schema path a rule names, so a test can hold each to a field the schema still has: a rule
11
+ * whose path was renamed away never matches, and its field silently falls back to the default.
12
+ * Paths under `sibling[].` name a field of a sibling row.
13
+ */
14
+ export declare const POLICY_CHANGE_RULE_PATHS: readonly string[];
15
+ /**
16
+ * Whether `after` lets agents do anything `before` did not, over the loaded configurations. Only a
17
+ * difference a rule above proves safe is non-widening; every other difference widens. `confirmed`
18
+ * names the answers whose recorded observation matches `after`; only those may leave `unconfirmed`.
19
+ */
20
+ export declare function policyChange(before: CockpitConfig, after: CockpitConfig, confirmed?: ReadonlySet<string>): PolicyChange;
21
+ type GuardConfigChange = Readonly<{
22
+ kind: 'unchanged' | 'unrecorded';
23
+ }> | PolicyChange;
24
+ /**
25
+ * How the current configuration differs from the one the guards were generated from. `recorded` is
26
+ * the copy sync kept of that configuration; one missing, or not the configuration `generatedSha256`
27
+ * names, cannot show the edit is safe. `confirmations` is the machine-local record `cockpit confirm`
28
+ * keeps of the observations that confirmed answers.
29
+ */
30
+ export declare function guardConfigChange(generatedSha256: string, recorded: Uint8Array | undefined, current: Readonly<{
31
+ sha256: string;
32
+ config: CockpitConfig;
33
+ }>, confirmations?: Uint8Array): GuardConfigChange;
34
+ /** Why a changed configuration needs a person before the guard applies it, for a hold or a check. */
35
+ export declare function guardConfigHeldReason(change: Exclude<GuardConfigChange, Readonly<{
36
+ kind: 'unchanged' | 'non-widening';
37
+ }>>): string;
38
+ /** What `check` reports about an edit the guards were not regenerated for, or nothing. */
39
+ export declare function inspectGuardConfigChange(root: string, configPath: string, current: Readonly<{
40
+ sha256: string;
41
+ config: CockpitConfig;
42
+ }>): Promise<readonly EnvironmentDiagnostic[]>;
43
+ export {};
@@ -0,0 +1,159 @@
1
+ // Owns whether an edit of cockpit.toml widens what agents may do. The guard holds every agent call
2
+ // once the configuration differs from the one it was generated from; this classification decides
3
+ // whether that hold needs a person (the edit widens) or only a sync an agent may run (it does not).
4
+ // It fails closed: a field with no rule here widens whenever it changes, so a field added to the
5
+ // schema later is a person's decision until someone writes down why it is not. A rule exists only
6
+ // for a field that cannot widen the guard's trust inputs (the commands agents run unprompted, the
7
+ // roots they write, the secrets they are kept from, who passes a gate) in the direction it admits.
8
+ // [guards], [work], [autonomy], [mcp], [testing], [browser] and every command field have none, so
9
+ // any change to them is a person's.
10
+ // The rules read the configuration rather than compare the guard policy each side would build: most
11
+ // of what they judge (Compose files and profiles, resettable volumes, the tools guarded, a sibling's
12
+ // operator gate) never reaches that policy, and building it observes the checkouts on disk.
13
+ import { join } from 'node:path';
14
+ import { isDeepStrictEqual } from 'node:util';
15
+ import { UNSYNCED_ADMITTED_PROSE } from '../command-table.js';
16
+ import { parseCockpitConfigBytes, readBoundedInput } from '../config/load.js';
17
+ import { hasErrorCode } from '../environment/contract.js';
18
+ import { cockpitCommand } from '../fix-catalogue.js';
19
+ import { sha256Hex } from '../sha256.js';
20
+ import { ANSWER_CONFIRMATION_PATH, confirmedAnswers } from './answer-confirmation.js';
21
+ import { GUARD_LOCAL_PATHS, parseGuardLocalIdentity } from './local-identity.js';
22
+ const safe = () => [];
23
+ const gains = (from, to) => to.some(row => !from.some(was => isDeepStrictEqual(was, row)));
24
+ /** A list that may lose rows but not gain one. */
25
+ const onlyRemoves = (before = [], after = [], at) => gains(before, after) ? [at] : [];
26
+ /** A list that may gain rows but not lose one. */
27
+ const onlyAdds = (before = [], after = [], at) => gains(after, before) ? [at] : [];
28
+ function plainObject(value) {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ }
31
+ /** The paths under `at` that differ in a way no rule admits; a rule is looked up by its path from the walk's root. */
32
+ function walk(before, after, rules, at, key = '') {
33
+ const rule = rules[key];
34
+ if (rule)
35
+ return [...rule(before, after, at)];
36
+ if (plainObject(before) && plainObject(after)) {
37
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap(name => walk(before[name], after[name], rules, at ? `${at}.${name}` : name, key ? `${key}.${name}` : name));
38
+ }
39
+ return isDeepStrictEqual(before, after) ? [] : [at];
40
+ }
41
+ // The fields of an existing sibling that say what it is for. Everything else about a sibling decides
42
+ // what the agent may write or read: its directory is a writable root, its environment files are the
43
+ // instances the guard protects, its guides are copied into every agent's instructions, and its derived
44
+ // MCP sources start processes outside the guard.
45
+ const SIBLING_RULES = {
46
+ purpose: safe,
47
+ host_reason: safe,
48
+ // Only the stale-stack warning reads these.
49
+ rebuild_inputs: safe,
50
+ // An operator gate keeps a sibling out of default runs; adding one narrows, removing one widens.
51
+ gate: (before, after, at) => before !== undefined && after === undefined ? [at] : [],
52
+ };
53
+ // A new sibling is a new root the agent may write, wherever its directory is, so only a removed or
54
+ // described one is compared field by field.
55
+ const siblings = (before, after, at) => [
56
+ ...before.flatMap(row => {
57
+ const now = after.find(candidate => candidate.slug === row.slug);
58
+ // A removed sibling takes its instance protection with it, and its directory leaves the guard's view.
59
+ return now ? walk(row, now, SIBLING_RULES, `${at}.${row.slug}`) : [`${at}.${row.slug}`];
60
+ }),
61
+ ...after.filter(row => !before.some(candidate => candidate.slug === row.slug)).map(row => `${at}.${row.slug}`),
62
+ ];
63
+ // A policy row admits its build and test commands in every sibling of its toolchain.
64
+ const installPolicies = (before, after, at) => after.flatMap(row => {
65
+ const was = before.find(candidate => candidate.toolchain === row.toolchain && candidate.lockfile === row.lockfile);
66
+ return ['build', 'test'].filter(kind => row[kind] !== undefined && !isDeepStrictEqual(row[kind], was?.[kind]))
67
+ .map(kind => `${at}.${row.toolchain}:${row.lockfile}.${kind}`);
68
+ });
69
+ // Keyed by schema path from the configuration's root.
70
+ const CHANGE_RULES = {
71
+ // Removing an entry would claim a person stated an answer nobody stated; policyChange admits the
72
+ // removal of one an observation recorded as confirmed for exactly the new configuration.
73
+ unconfirmed: onlyAdds,
74
+ 'cockpit.guide_cap': safe,
75
+ sibling: siblings,
76
+ ports: safe,
77
+ 'environment.compose_files': onlyRemoves,
78
+ // A profile starts more of the Compose files' services, as a new Compose file does.
79
+ 'environment.profiles': onlyRemoves,
80
+ 'environment.install_policies': installPolicies,
81
+ // Reset deletes exactly these volumes.
82
+ 'environment.resettable_volumes': onlyRemoves,
83
+ // Addresses derived from leased ports, and the floor the host is held to: neither reaches the guard.
84
+ 'environment.entrypoint_derivatives': safe,
85
+ 'environment.machine': safe,
86
+ // Removing a tool deletes its guard registration.
87
+ 'surfaces.tools': onlyAdds,
88
+ // Only docs check reads living documents. The decision paths stay a person's: work orders
89
+ // never count changes under them as out of scope.
90
+ 'docs.living': safe,
91
+ };
92
+ /**
93
+ * Every schema path a rule names, so a test can hold each to a field the schema still has: a rule
94
+ * whose path was renamed away never matches, and its field silently falls back to the default.
95
+ * Paths under `sibling[].` name a field of a sibling row.
96
+ */
97
+ export const POLICY_CHANGE_RULE_PATHS = Object.freeze([...Object.keys(CHANGE_RULES),
98
+ ...Object.keys(SIBLING_RULES).map(field => `sibling[].${field}`)]);
99
+ /**
100
+ * Whether `after` lets agents do anything `before` did not, over the loaded configurations. Only a
101
+ * difference a rule above proves safe is non-widening; every other difference widens. `confirmed`
102
+ * names the answers whose recorded observation matches `after`; only those may leave `unconfirmed`.
103
+ */
104
+ export function policyChange(before, after, confirmed = new Set()) {
105
+ const unconfirmed = (from = [], to = [], at) => gains(to, from.filter(answer => !confirmed.has(answer))) ? [at] : [];
106
+ const fields = walk(before, after, { ...CHANGE_RULES, unconfirmed }, '');
107
+ return fields.length ? { kind: 'widening', fields: [...new Set(fields)] } : { kind: 'non-widening' };
108
+ }
109
+ /**
110
+ * How the current configuration differs from the one the guards were generated from. `recorded` is
111
+ * the copy sync kept of that configuration; one missing, or not the configuration `generatedSha256`
112
+ * names, cannot show the edit is safe. `confirmations` is the machine-local record `cockpit confirm`
113
+ * keeps of the observations that confirmed answers.
114
+ */
115
+ export function guardConfigChange(generatedSha256, recorded, current, confirmations) {
116
+ if (current.sha256 === generatedSha256)
117
+ return { kind: 'unchanged' };
118
+ const before = recorded && sha256Hex(recorded) === generatedSha256 ? parseCockpitConfigBytes(recorded) : undefined;
119
+ return before ? policyChange(before, current.config, confirmedAnswers(confirmations, current.config)) : { kind: 'unrecorded' };
120
+ }
121
+ /** Why a changed configuration needs a person before the guard applies it, for a hold or a check. */
122
+ export function guardConfigHeldReason(change) {
123
+ return change.kind === 'widening'
124
+ ? `it widens what agents may do (${change.fields.join(', ')})`
125
+ : `the configuration they were generated from is not recorded in ${GUARD_LOCAL_PATHS.generatedConfig}, so the edit cannot be shown not to widen what agents may do`;
126
+ }
127
+ /** What `check` reports about an edit the guards were not regenerated for, or nothing. */
128
+ export async function inspectGuardConfigChange(root, configPath, current) {
129
+ const local = async (path) => {
130
+ try {
131
+ const read = await readBoundedInput(join(root, path));
132
+ return read.kind === 'complete' ? read.bytes : undefined;
133
+ }
134
+ catch (error) {
135
+ if (hasErrorCode(error, 'ENOENT'))
136
+ return undefined;
137
+ throw error;
138
+ }
139
+ };
140
+ const identityBytes = await local(GUARD_LOCAL_PATHS.identity);
141
+ // No guard was generated on this machine, so nothing holds the agent.
142
+ if (!identityBytes)
143
+ return [];
144
+ const identity = parseGuardLocalIdentity(identityBytes.toString('utf8'));
145
+ if (!identity)
146
+ return [];
147
+ const change = guardConfigChange(identity.configSha256, await local(GUARD_LOCAL_PATHS.generatedConfig), current, await local(ANSWER_CONFIRMATION_PATH));
148
+ if (change.kind === 'unchanged')
149
+ return [];
150
+ if (change.kind === 'non-widening')
151
+ return [{ severity: 'warning', target: 'guard',
152
+ code: 'GUARD_CONFIG_UNSYNCED',
153
+ message: `cockpit.toml changed since the guards were generated without widening what agents may do; the guard holds every agent tool call except ${UNSYNCED_ADMITTED_PROSE} until it is synced`,
154
+ fix: `run ${cockpitCommand('sync', { config: configPath })}; an agent may run it where [autonomy] sync = "agent"` }];
155
+ return [{ severity: 'error', target: 'guard',
156
+ code: 'GUARD_CONFIG_WIDENED',
157
+ message: `cockpit.toml changed since the guards were generated and ${guardConfigHeldReason(change)}; the guard holds every agent tool call until a person syncs`,
158
+ fix: `a person reads the change and runs ${cockpitCommand('sync', { config: configPath })} in a terminal outside the agent, or the change is reverted` }];
159
+ }
@@ -1,4 +1,4 @@
1
- import type { GuardDecision } from './evaluator.js';
1
+ import { type GuardDecision } from './evaluator.js';
2
2
  import type { GuardFileContent, ParsedInvocation } from './invocation-parser.js';
3
3
  /**
4
4
  * The denial for a tool call that would change the pinned policy tables, or undefined. `isConfig`
@@ -8,6 +8,7 @@
8
8
  import { posix } from 'node:path';
9
9
  import { isDeepStrictEqual } from 'node:util';
10
10
  import { pinnedPolicyTables } from '../autonomy.js';
11
+ import { innerCommand } from './evaluator.js';
11
12
  import { classifyReadOnlyInvocation, cockpitLauncherPrefix } from './read-only.js';
12
13
  const denial = (reason) => ({ decision: 'deny', reason });
13
14
  const PINNED = '[work], [work.gates] and [autonomy] (and [mcp] while [autonomy] mcp_approve is not agent)';
@@ -104,24 +105,25 @@ function recordsWithoutWriting(invocation) {
104
105
  return false;
105
106
  }
106
107
  /**
107
- * The invocations the pin judges. A cockpit session runs the words after its `--` as a command of
108
- * its own, so that command is judged as one more invocation and the session by the words before it.
108
+ * The invocations the pin judges. A cockpit session or exec runs the words after its `--` as a
109
+ * command of its own, so that command is judged as one more invocation and the wrapper by the words
110
+ * before it. Exec's command runs in a product container, where neither a read-only spelling nor a
111
+ * cockpit launcher proves what the program is, so naming the configuration there is never excused.
109
112
  */
110
113
  function judgedInvocations(invocations) {
111
114
  const judged = [];
112
- const pending = [...invocations];
115
+ const pending = invocations.map(invocation => ({ ...invocation, contained: false }));
113
116
  for (let invocation = pending.shift(); invocation; invocation = pending.shift()) {
114
117
  const prefix = invocation.executable === null ? undefined : cockpitLauncherPrefix(invocation.executable, invocation.argv);
115
- const separator = prefix !== undefined && invocation.argv[prefix] === 'session' ? invocation.argv.indexOf('--', prefix) : -1;
116
- if (separator < 0) {
118
+ const inner = prefix === undefined ? undefined : innerCommand(invocation.argv.slice(prefix));
119
+ if (!inner) {
117
120
  judged.push(invocation);
118
121
  continue;
119
122
  }
120
- judged.push({ ...invocation, argv: invocation.argv.slice(0, separator) });
121
- const command = invocation.argv.slice(separator + 1);
122
- if (command.length)
123
- pending.push({ executable: command[0], argv: command.slice(1), cwd: invocation.cwd, wrapped: false, redirects: [],
124
- words: command.map((value, index) => ({ role: index ? 'argument' : 'executable', value })) });
123
+ judged.push({ ...invocation, argv: invocation.argv.slice(0, invocation.argv.length - inner.words.length - 1) });
124
+ pending.push({ executable: inner.words[0], argv: inner.words.slice(1), cwd: invocation.cwd, wrapped: false, redirects: [],
125
+ words: inner.words.map((value, index) => ({ role: index ? 'argument' : 'executable', value })),
126
+ contained: invocation.contained || inner.runs === 'container' });
125
127
  }
126
128
  return judged;
127
129
  }
@@ -141,13 +143,13 @@ export function pinnedPolicyDenial(input, current, isConfig) {
141
143
  continue;
142
144
  // A proven read leaves the file as it is, and staging or committing it records the text the
143
145
  // guard already checked.
144
- if (!writes && (classifyReadOnlyInvocation(invocation) || recordsWithoutWriting(invocation)))
146
+ if (!writes && !invocation.contained && (classifyReadOnlyInvocation(invocation) || recordsWithoutWriting(invocation)))
145
147
  continue;
146
148
  // Installed cockpit reads the file it is given with --config, but a path or `node <script>`
147
149
  // spells any program as well as cockpit. Whether it starts cockpit is the evaluator's
148
150
  // launcher proof, so only the --config operand is left to it; naming the file anywhere
149
151
  // else is denied whatever the spelling starts.
150
- const prefix = invocation.executable === null ? undefined : cockpitLauncherPrefix(invocation.executable, invocation.argv);
152
+ const prefix = invocation.executable === null || invocation.contained ? undefined : cockpitLauncherPrefix(invocation.executable, invocation.argv);
151
153
  const configOperand = (index) => prefix !== undefined && index >= prefix
152
154
  && (invocation.argv[index].startsWith('--config=') || index > prefix && invocation.argv[index - 1] === '--config');
153
155
  if (!writes && naming.every(configOperand))
@@ -1,6 +1,6 @@
1
1
  import type { SurfaceTarget } from '../surfaces/contract.js';
2
2
  import { type CockpitRootQuestion, type InitApplicationInput, type InitApplicationOutcome } from '../environment/init-application.js';
3
- export interface InitCommandInput {
3
+ interface InitCommandInput {
4
4
  readonly root: string;
5
5
  readonly target: SurfaceTarget;
6
6
  /** Every chosen tool, target first; just the target when only one was given. */
@@ -22,7 +22,7 @@ export interface UnattendedInit {
22
22
  readonly answers: Readonly<Record<string, unknown>>;
23
23
  readonly defaultsSha256?: string;
24
24
  }
25
- export interface InitCommandOptions {
25
+ interface InitCommandOptions {
26
26
  readonly interactive: boolean;
27
27
  readonly json: boolean;
28
28
  readonly unattended?: UnattendedInit;
@@ -36,5 +36,6 @@ export interface InitCommandIo {
36
36
  /** Creates and initialises one repository beside the product, or initialises its existing plain directory; it never creates parents. */
37
37
  readonly createCockpitRoot?: (path: string) => Promise<void>;
38
38
  }
39
- export type InitApplicationHandler = (input: InitApplicationInput) => Promise<InitApplicationOutcome>;
39
+ type InitApplicationHandler = (input: InitApplicationInput) => Promise<InitApplicationOutcome>;
40
40
  export declare function runInitOperatorCommand(input: InitCommandInput, options: InitCommandOptions, io: InitCommandIo, application: InitApplicationHandler): Promise<InitApplicationOutcome>;
41
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { initCommand } from '../fix-catalogue.js';
3
+ import { canonicalJson } from '../canonical-json.js';
3
4
  import { runInitApplication, unsupportedInitPlatformOutcome, } from '../environment/init-application.js';
4
5
  import { refusalOutcome as refusal } from '../refusal.js';
5
6
  // Real interviews ask up to eleven questions; the bound only stops an application that never converges from looping forever.
@@ -8,14 +9,16 @@ const ANSWER_FIELDS = ['cockpit.root', 'cockpit.name', 'sibling', 'estate.runnab
8
9
  'environment.install_policies', 'environment.test_data', 'environment.compose_files',
9
10
  'environment.machine', 'environment.posture', 'testing', 'guards'];
10
11
  /**
11
- * The answers file's optional [provenance] table says who chose an answer: "human" (the default) or
12
- * "agent-default" when the setup agent picked it because the person let it choose. Without it, every
13
- * value an agent invented was written into cockpit.toml as something the operator stated.
12
+ * The answers file's optional [provenance] table says who chose an answer: "human" (the default),
13
+ * "agent-default" when the setup agent picked it because the person let it choose, or "derived" when
14
+ * the setup agent accepts the value init read from exact sources. Without it, every value an agent
15
+ * invented was written into cockpit.toml as something the operator stated. A derived field needs no
16
+ * value in the file: init answers it with what it read, and the document owner checks it either way.
14
17
  */
15
18
  const PROVENANCE_TABLE = 'provenance';
16
- const PROVENANCE_VALUES = { human: 'operator-confirmed', 'agent-default': 'agent-default' };
17
- // cockpit never proposes these, and the docs say they come from a person; an answers file that does
18
- // not attribute one is recorded as unattributed rather than as the person's.
19
+ const PROVENANCE_VALUES = { human: 'operator-confirmed', 'agent-default': 'agent-default', derived: 'derived' };
20
+ // cockpit proposes at most part of the machine floor, since free disk and the cold start time have no
21
+ // source; an answers file that does not attribute one is recorded as unattributed rather than as the person's.
19
22
  const UNATTRIBUTED_UNLESS_MARKED = ['environment.machine'];
20
23
  const isTable = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
21
24
  /** Flattens the answers file to field -> value, naming any key that matches no interview field. */
@@ -45,7 +48,7 @@ function answerProvenance(answers, answered) {
45
48
  if (table === undefined)
46
49
  return { marks };
47
50
  if (!isTable(table))
48
- return { refusal: refusal('INIT_ANSWER_INVALID', `${PROVENANCE_TABLE} must be a table of answer field = "human" or "agent-default"`) };
51
+ return { refusal: refusal('INIT_ANSWER_INVALID', `${PROVENANCE_TABLE} must be a table of answer field = "human", "agent-default" or "derived"`) };
49
52
  const problems = [];
50
53
  const walk = (current, prefix) => {
51
54
  for (const [key, value] of Object.entries(current)) {
@@ -58,9 +61,9 @@ function answerProvenance(answers, answered) {
58
61
  problems.push(`provenance names no recorded interview answer: ${path}`);
59
62
  }
60
63
  else if (typeof value !== 'string' || PROVENANCE_VALUES[value] === undefined) {
61
- problems.push(`provenance.${path} must be "human" or "agent-default"`);
64
+ problems.push(`provenance.${path} must be "human", "agent-default" or "derived"`);
62
65
  }
63
- else if (!answered.has(path)) {
66
+ else if (!answered.has(path) && value !== 'derived') {
64
67
  problems.push(`provenance names ${path}, which the answers file does not answer`);
65
68
  }
66
69
  else
@@ -87,7 +90,19 @@ function noteInterview(asked, outcome) {
87
90
  for (const field of [...interview.answered, ...interview.outstanding.map(row => row.field)])
88
91
  asked.add(field);
89
92
  }
90
- function unattendedAnswer(field, expectedSha256, supplied, unattended) {
93
+ /**
94
+ * The value init read for a field from exact sources, as the pending run proposed it; "answered"
95
+ * when an earlier run already recorded the field, whose recorded answer stays in force.
96
+ */
97
+ function derivedProposal(field, pending) {
98
+ const interview = pending.result?.interview;
99
+ if (interview?.answered.includes(field))
100
+ return 'answered';
101
+ const proposal = interview?.outstanding.find(row => row.field === field)?.proposals
102
+ .find(row => row.target === field && row.provenance.kind === 'derived');
103
+ return proposal && { value: proposal.value };
104
+ }
105
+ function unattendedAnswer(field, expectedSha256, supplied, unattended, marks, pending) {
91
106
  if (field === 'configuration.defaults') {
92
107
  if (unattended.defaultsSha256 === undefined)
93
108
  return { kind: 'missing' };
@@ -95,7 +110,15 @@ function unattendedAnswer(field, expectedSha256, supplied, unattended) {
95
110
  return { kind: 'refusal', outcome: refusal('INIT_DEFAULTS_NOT_ACCEPTED', '--accept-defaults does not equal the digest of the surfaced defaults; rerun without it to read the current digest') };
96
111
  return { kind: 'answer', bytes: JSON.stringify({ confirmed: true, sha256: unattended.defaultsSha256 }) };
97
112
  }
98
- const value = supplied.get(field);
113
+ let value = supplied.get(field);
114
+ if (value === undefined && marks.get(field) === 'derived') {
115
+ const derived = derivedProposal(field, pending);
116
+ if (derived === 'answered')
117
+ return { kind: 'missing' };
118
+ if (derived === undefined)
119
+ return { kind: 'refusal', outcome: refusal('INIT_ANSWER_INVALID', `provenance.${field} is "derived", but init read no complete value for ${field} from an exact source; answer it in the answers file and mark it "human" once a person states it, or "agent-default" when you chose it`) };
120
+ value = derived.value;
121
+ }
99
122
  if (value === undefined)
100
123
  return { kind: 'missing' };
101
124
  try {
@@ -105,9 +128,6 @@ function unattendedAnswer(field, expectedSha256, supplied, unattended) {
105
128
  return { kind: 'refusal', outcome: refusal('INIT_ANSWER_INVALID', `the answer for ${field} cannot be written as JSON (${error instanceof Error ? error.message : 'unknown error'})`) };
106
129
  }
107
130
  }
108
- const canonicalJson = (value) => Array.isArray(value) ? `[${value.map(canonicalJson).join(',')}]`
109
- : isTable(value) ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`
110
- : JSON.stringify(value);
111
131
  /**
112
132
  * An answer an earlier run recorded stays in force, so a different value in the file must not be
113
133
  * accepted quietly: the person approved the file's bytes, not the recorded ones.
@@ -123,7 +143,7 @@ function recordedAnswerConflict(input, preview, supplied, marks, unattended) {
123
143
  // The defaults are accepted by digest, not by the answers file, and are bound to the draft they confirmed.
124
144
  if (field === 'configuration.defaults')
125
145
  continue;
126
- const now = unattendedAnswer(field, undefined, supplied, unattended);
146
+ const now = unattendedAnswer(field, undefined, supplied, unattended, marks, preview);
127
147
  if (now.kind === 'refusal')
128
148
  return now.outcome;
129
149
  if (now.kind !== 'answer' || recorded[index] === undefined)
@@ -274,7 +294,7 @@ async function drive(input, options, io, application, asked) {
274
294
  // A person typing at the terminal states their own answer.
275
295
  let answerProvenanceValue = 'operator-confirmed';
276
296
  if (unattended && supplied) {
277
- const resolved = unattendedAnswer(question.field, question.expectedSha256, supplied.fields, unattended);
297
+ const resolved = unattendedAnswer(question.field, question.expectedSha256, supplied.fields, unattended, marks, outcome);
278
298
  if (resolved.kind === 'refusal')
279
299
  return resolved.outcome;
280
300
  // An unanswered field is not a failure: the pending result lists what is still outstanding.
@@ -1,17 +1,18 @@
1
1
  import { type Diagnostic } from '../environment/contract.js';
2
- export interface InferredProvenance {
3
- readonly kind: 'inferred';
2
+ interface InferredProvenance {
3
+ readonly kind: 'derived' | 'inferred';
4
+ /** The file the value was read from, as file:line where one line holds it. */
4
5
  readonly source: string;
5
6
  readonly reason: string;
6
7
  }
7
- export interface InferredFact {
8
+ interface InferredFact {
8
9
  /** Interview fields this fact helps answer. */
9
10
  readonly fields: readonly string[];
10
11
  readonly target: string;
11
12
  readonly value: unknown;
12
13
  readonly provenance: InferredProvenance;
13
14
  }
14
- export interface SkippedInference {
15
+ interface SkippedInference {
15
16
  readonly source: string;
16
17
  readonly reason: string;
17
18
  }
@@ -19,7 +20,7 @@ export interface InferenceResult {
19
20
  readonly facts: readonly InferredFact[];
20
21
  readonly skipped: readonly SkippedInference[];
21
22
  }
22
- export interface InferenceInput {
23
+ interface InferenceInput {
23
24
  /** Files must stay under this observed authority root. */
24
25
  readonly root: string;
25
26
  /** Paths surfaced for confirmation are relative to the cockpit that will store them. */
@@ -30,7 +31,7 @@ export interface InferenceInput {
30
31
  }>[];
31
32
  }
32
33
  export declare const COMPOSE_NAMES: readonly ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
33
- export interface DockerfileFacts {
34
+ interface DockerfileFacts {
34
35
  readonly command?: string[];
35
36
  readonly ports: number[];
36
37
  }
@@ -49,3 +50,4 @@ export declare function composeFindings(input: Readonly<{
49
50
  root: string;
50
51
  draft: string;
51
52
  }>): Promise<readonly Diagnostic[]>;
53
+ export {};