@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.
- package/CHANGELOG.md +99 -0
- package/README.md +3 -0
- package/cockpit.schema.json +73 -3
- package/dist/autonomy.d.ts +3 -2
- package/dist/autonomy.js +30 -13
- package/dist/browser/commands.d.ts +57 -2
- package/dist/browser/commands.js +114 -10
- package/dist/browser/runtime.d.ts +1 -0
- package/dist/browser/runtime.js +4 -1
- package/dist/browser/surfaces.d.ts +7 -7
- package/dist/browser/surfaces.js +5 -0
- package/dist/canonical-json.d.ts +2 -0
- package/dist/canonical-json.js +5 -0
- package/dist/cli-main.d.ts +40 -3
- package/dist/cli-main.js +241 -62
- package/dist/command-table.d.ts +19 -11
- package/dist/command-table.js +190 -38
- package/dist/config/load.d.ts +16 -4
- package/dist/config/load.js +25 -3
- package/dist/config/unconfirmed-toml.d.ts +9 -0
- package/dist/config/unconfirmed-toml.js +29 -0
- package/dist/diagnostics.js +10 -1
- package/dist/docs/check.d.ts +16 -0
- package/dist/docs/check.js +197 -0
- package/dist/docs/decision-record.d.ts +34 -0
- package/dist/docs/decision-record.js +135 -0
- package/dist/docs/decisions.d.ts +16 -0
- package/dist/docs/decisions.js +103 -0
- package/dist/docs/markdown.d.ts +36 -0
- package/dist/docs/markdown.js +88 -0
- package/dist/docs/references.d.ts +32 -0
- package/dist/docs/references.js +148 -0
- package/dist/environment/application/confirm.d.ts +35 -0
- package/dist/environment/application/confirm.js +122 -0
- package/dist/environment/application/destructive.js +6 -2
- package/dist/environment/application/doctor.d.ts +12 -7
- package/dist/environment/application/doctor.js +20 -8
- package/dist/environment/application/guard.d.ts +12 -6
- package/dist/environment/application/guard.js +87 -32
- package/dist/environment/application/health.d.ts +14 -5
- package/dist/environment/application/health.js +8 -2
- package/dist/environment/application/lifecycle-context.d.ts +10 -3
- package/dist/environment/application/lifecycle-state.js +4 -3
- package/dist/environment/application/lifecycle.d.ts +2 -1
- package/dist/environment/application/lifecycle.js +11 -3
- package/dist/environment/application/services.d.ts +60 -0
- package/dist/environment/application/services.js +89 -0
- package/dist/environment/application/siblings.d.ts +43 -0
- package/dist/environment/application/siblings.js +269 -0
- package/dist/environment/claims.d.ts +10 -10
- package/dist/environment/claims.js +3 -11
- package/dist/environment/compose-topology.d.ts +15 -7
- package/dist/environment/compose-topology.js +100 -23
- package/dist/environment/contract.d.ts +42 -9
- package/dist/environment/contract.js +33 -0
- package/dist/environment/executable.d.ts +12 -0
- package/dist/environment/executable.js +46 -23
- package/dist/environment/generated-application/check.js +2 -0
- package/dist/environment/generated-application/prepare.js +2 -0
- package/dist/environment/generated-application/shared.d.ts +10 -2
- package/dist/environment/generated-application/shared.js +6 -1
- package/dist/environment/generated-application/surface-outputs.js +23 -12
- package/dist/environment/generated-application/surfaces.js +16 -11
- package/dist/environment/generated-application/sync.js +19 -4
- package/dist/environment/git-observation.d.ts +25 -6
- package/dist/environment/git-observation.js +37 -28
- package/dist/environment/health-composition.d.ts +3 -2
- package/dist/environment/health-composition.js +19 -2
- package/dist/environment/init-application.d.ts +39 -14
- package/dist/environment/init-application.js +39 -10
- package/dist/environment/lifecycle-composition.d.ts +4 -2
- package/dist/environment/lifecycle-composition.js +84 -17
- package/dist/environment/lifecycle-stage.d.ts +8 -3
- package/dist/environment/lifecycle-stage.js +11 -1
- package/dist/environment/logs.d.ts +1 -1
- package/dist/environment/map-limit.d.ts +2 -0
- package/dist/environment/map-limit.js +13 -0
- package/dist/environment/mcp-observation.d.ts +9 -0
- package/dist/environment/mcp-observation.js +46 -0
- package/dist/environment/observations.d.ts +1 -1
- package/dist/environment/observations.js +2 -0
- package/dist/environment/operator-application.d.ts +35 -6
- package/dist/environment/operator-application.js +42 -0
- package/dist/environment/presence.d.ts +4 -0
- package/dist/environment/presence.js +26 -0
- package/dist/environment/read-composition.d.ts +8 -3
- package/dist/environment/read-composition.js +53 -8
- package/dist/environment/readiness-composition.d.ts +20 -1
- package/dist/environment/readiness-composition.js +23 -2
- package/dist/environment/reclaim.d.ts +56 -19
- package/dist/environment/reclaim.js +147 -42
- package/dist/environment/runtime.d.ts +64 -18
- package/dist/environment/runtime.js +73 -3
- package/dist/environment/served-checkout-grants.d.ts +53 -0
- package/dist/environment/served-checkout-grants.js +182 -0
- package/dist/environment/sibling-checkouts.d.ts +119 -0
- package/dist/environment/sibling-checkouts.js +254 -0
- package/dist/environment/sibling-observation.d.ts +27 -0
- package/dist/environment/sibling-observation.js +45 -0
- package/dist/environment/sibling-pull.d.ts +23 -0
- package/dist/environment/sibling-pull.js +95 -0
- package/dist/environment/stack-inputs.d.ts +18 -0
- package/dist/environment/stack-inputs.js +210 -0
- package/dist/environment/stack-inventory-prune.d.ts +31 -0
- package/dist/environment/stack-inventory-prune.js +129 -0
- package/dist/environment/stack-inventory-report.d.ts +44 -0
- package/dist/environment/stack-inventory-report.js +78 -0
- package/dist/environment/stack-inventory.d.ts +94 -0
- package/dist/environment/stack-inventory.js +418 -0
- package/dist/environment/state.d.ts +48 -9
- package/dist/environment/state.js +142 -2
- package/dist/environment/tool-registration.js +3 -7
- package/dist/environment-exec/execute.d.ts +6 -3
- package/dist/environment-exec/execute.js +4 -0
- package/dist/fix-catalogue.d.ts +1 -1
- package/dist/generated-environment/adopt.js +4 -8
- package/dist/guards/answer-confirmation.d.ts +41 -0
- package/dist/guards/answer-confirmation.js +109 -0
- package/dist/guards/evaluator.d.ts +20 -3
- package/dist/guards/evaluator.js +39 -7
- package/dist/guards/local-identity.d.ts +6 -2
- package/dist/guards/local-identity.js +10 -4
- package/dist/guards/policy-change.d.ts +43 -0
- package/dist/guards/policy-change.js +159 -0
- package/dist/guards/policy-pin.d.ts +1 -1
- package/dist/guards/policy-pin.js +14 -12
- package/dist/init/command.d.ts +4 -3
- package/dist/init/command.js +36 -16
- package/dist/init/derive.d.ts +8 -6
- package/dist/init/derive.js +140 -36
- package/dist/init/draft.d.ts +13 -11
- package/dist/init/draft.js +54 -31
- package/dist/init/render.d.ts +18 -6
- package/dist/init/render.js +31 -9
- package/dist/repository-path.d.ts +9 -0
- package/dist/repository-path.js +16 -0
- package/dist/schema.d.ts +42 -75
- package/dist/schema.js +62 -11
- package/dist/surfaces/harness-registry.d.ts +80 -0
- package/dist/surfaces/harness-registry.js +29 -0
- package/dist/surfaces/mcp.d.ts +67 -17
- package/dist/surfaces/mcp.js +194 -31
- package/dist/surfaces/projection.d.ts +7 -7
- package/dist/surfaces/projection.js +41 -6
- package/dist/surfaces/render.d.ts +5 -44
- package/dist/surfaces/render.js +5 -2
- package/dist/team-metadata.js +2 -1
- package/dist/testing/test-protection.d.ts +34 -0
- package/dist/testing/test-protection.js +137 -0
- package/dist/testing/weakening.d.ts +15 -0
- package/dist/testing/weakening.js +30 -0
- package/dist/work-orders/application-closure.d.ts +7 -0
- package/dist/work-orders/application-closure.js +76 -0
- package/dist/work-orders/application-context.d.ts +162 -0
- package/dist/work-orders/application-context.js +137 -0
- package/dist/work-orders/application-decisions.d.ts +27 -0
- package/dist/work-orders/application-decisions.js +121 -0
- package/dist/work-orders/application-grading.d.ts +11 -0
- package/dist/work-orders/application-grading.js +212 -0
- package/dist/work-orders/application.d.ts +4 -97
- package/dist/work-orders/application.js +94 -254
- package/dist/work-orders/composition.d.ts +35 -58
- package/dist/work-orders/composition.js +216 -24
- package/dist/work-orders/contract.d.ts +22 -6
- package/dist/work-orders/contract.js +34 -16
- package/dist/work-orders/decisions.d.ts +23 -0
- package/dist/work-orders/decisions.js +55 -0
- package/dist/work-orders/store.d.ts +54 -5
- package/dist/work-orders/store.js +118 -24
- package/docs/README.md +6 -3
- package/docs/capability-matrix.md +17 -9
- package/docs/cli-reference.md +209 -1
- package/docs/command-list.md +12 -1
- package/docs/concepts.md +6 -4
- package/docs/configuration-essentials.md +7 -2
- package/docs/configuration-reference.md +18 -3
- package/docs/docs-check.md +76 -0
- package/docs/environment.md +194 -6
- package/docs/faq.md +5 -3
- package/docs/getting-started.md +109 -25
- package/docs/harness-surfaces.md +69 -2
- package/docs/safety-and-autonomy.md +56 -12
- package/docs/troubleshooting.md +29 -8
- package/docs/tutorial.md +3 -2
- package/docs/work-and-browser.md +180 -14
- package/libexec/browser-operation.cjs +86 -2
- package/package.json +2 -2
- package/surfaces/skills/cockpit-browser.skill.md +7 -2
- package/surfaces/skills/cockpit-explain.skill.md +1 -1
- package/surfaces/skills/cockpit-fix.skill.md +1 -1
- package/surfaces/skills/cockpit-setup.skill.md +3 -3
- package/surfaces/skills/cockpit-work.skill.md +1 -1
- package/surfaces/skills/procedures/explain.md +9 -4
- package/surfaces/skills/procedures/fix.md +25 -11
- package/surfaces/skills/procedures/setup.md +113 -40
- 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
|
-
|
|
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 =
|
|
67
|
-
const exact = (candidate) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/fix-catalogue.d.ts
CHANGED
|
@@ -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 ||
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
36
|
+
interface GuardDeclaredAllow {
|
|
21
37
|
readonly argv: readonly string[];
|
|
22
38
|
readonly reason: string;
|
|
23
39
|
readonly cwd?: string;
|
|
24
40
|
}
|
|
25
|
-
|
|
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
|
-
|
|
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 {};
|
package/dist/guards/evaluator.js
CHANGED
|
@@ -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,
|
|
1033
|
-
//
|
|
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
|
|
1037
|
-
|
|
1038
|
-
if (!command.length)
|
|
1060
|
+
const command = cockpit && innerCommand(cockpit);
|
|
1061
|
+
if (!command)
|
|
1039
1062
|
return [invocation];
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
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) {
|