@cleocode/core 2026.10.18 → 2026.10.19
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/dist/build-provenance.json +2 -2
- package/dist/check/pr-gate.js +1 -1
- package/dist/check/pr-gate.js.map +1 -1
- package/dist/cloud/nexus-vault.d.ts +4 -3
- package/dist/cloud/nexus-vault.d.ts.map +1 -1
- package/dist/cloud/nexus-vault.js +62 -22
- package/dist/cloud/nexus-vault.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -0
- package/dist/config.js.map +1 -1
- package/dist/docs/conduit-history-backfill.d.ts +210 -0
- package/dist/docs/conduit-history-backfill.d.ts.map +1 -0
- package/dist/docs/conduit-history-backfill.js +451 -0
- package/dist/docs/conduit-history-backfill.js.map +1 -0
- package/dist/docs/docs-ops.d.ts +33 -0
- package/dist/docs/docs-ops.d.ts.map +1 -1
- package/dist/docs/docs-ops.js +72 -0
- package/dist/docs/docs-ops.js.map +1 -1
- package/dist/docs/docs-update.d.ts.map +1 -1
- package/dist/docs/docs-update.js +71 -0
- package/dist/docs/docs-update.js.map +1 -1
- package/dist/docs/doctor.d.ts +18 -4
- package/dist/docs/doctor.d.ts.map +1 -1
- package/dist/docs/doctor.js +124 -3
- package/dist/docs/doctor.js.map +1 -1
- package/dist/docs/section-merge.d.ts +70 -9
- package/dist/docs/section-merge.d.ts.map +1 -1
- package/dist/docs/section-merge.js +167 -10
- package/dist/docs/section-merge.js.map +1 -1
- package/dist/docs/section-write-path.d.ts.map +1 -1
- package/dist/docs/section-write-path.js +23 -8
- package/dist/docs/section-write-path.js.map +1 -1
- package/dist/gateway-client/generated/types.gen.d.ts +4 -0
- package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
- package/dist/go/driver.d.ts +29 -145
- package/dist/go/driver.d.ts.map +1 -1
- package/dist/go/driver.js +75 -168
- package/dist/go/driver.js.map +1 -1
- package/dist/go/index.d.ts +1 -1
- package/dist/go/index.d.ts.map +1 -1
- package/dist/go/index.js +1 -1
- package/dist/go/index.js.map +1 -1
- package/dist/goal/close.d.ts +1 -1
- package/dist/goal/close.js +2 -2
- package/dist/goal/close.js.map +1 -1
- package/dist/goal/draft.d.ts +10 -3
- package/dist/goal/draft.d.ts.map +1 -1
- package/dist/goal/draft.js +14 -9
- package/dist/goal/draft.js.map +1 -1
- package/dist/goal/index.d.ts +9 -12
- package/dist/goal/index.d.ts.map +1 -1
- package/dist/goal/index.js +9 -12
- package/dist/goal/index.js.map +1 -1
- package/dist/goal/runtime.d.ts +126 -0
- package/dist/goal/runtime.d.ts.map +1 -0
- package/dist/goal/runtime.js +289 -0
- package/dist/goal/runtime.js.map +1 -0
- package/dist/goal/status.d.ts +39 -0
- package/dist/goal/status.d.ts.map +1 -0
- package/dist/goal/status.js +101 -0
- package/dist/goal/status.js.map +1 -0
- package/dist/hooks/project-runner.d.ts +8 -3
- package/dist/hooks/project-runner.d.ts.map +1 -1
- package/dist/hooks/project-runner.js +104 -14
- package/dist/hooks/project-runner.js.map +1 -1
- package/dist/hooks/project-state.js +1 -1
- package/dist/hooks/project-state.js.map +1 -1
- package/dist/internal.d.ts +7 -3
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +5 -3
- package/dist/internal.js.map +1 -1
- package/dist/lifecycle/engine-ops.d.ts.map +1 -1
- package/dist/lifecycle/engine-ops.js +9 -6
- package/dist/lifecycle/engine-ops.js.map +1 -1
- package/dist/memory/retrieval/observe.d.ts.map +1 -1
- package/dist/memory/retrieval/observe.js +4 -8
- package/dist/memory/retrieval/observe.js.map +1 -1
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -1
- package/dist/paths.js.map +1 -1
- package/dist/playbooks/cantbook-profile.d.ts +3 -3
- package/dist/playbooks/cantbook-profile.js +3 -3
- package/dist/playbooks/pi-runner-wiring.d.ts +3 -3
- package/dist/playbooks/pi-runner-wiring.js +3 -3
- package/dist/release/ci-evidence.d.ts +28 -0
- package/dist/release/ci-evidence.d.ts.map +1 -1
- package/dist/release/ci-evidence.js +76 -20
- package/dist/release/ci-evidence.js.map +1 -1
- package/dist/release/open.d.ts +11 -0
- package/dist/release/open.d.ts.map +1 -1
- package/dist/release/open.js +49 -10
- package/dist/release/open.js.map +1 -1
- package/dist/release/preflight-skip.d.ts +50 -0
- package/dist/release/preflight-skip.d.ts.map +1 -1
- package/dist/release/preflight-skip.js +151 -34
- package/dist/release/preflight-skip.js.map +1 -1
- package/dist/release/reconcile.d.ts.map +1 -1
- package/dist/release/reconcile.js +6 -3
- package/dist/release/reconcile.js.map +1 -1
- package/dist/scaffold/ensure-config.d.ts +1 -1
- package/dist/scaffold/ensure-config.d.ts.map +1 -1
- package/dist/scaffold/ensure-config.js +2 -0
- package/dist/scaffold/ensure-config.js.map +1 -1
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/index.js.map +1 -1
- package/dist/security/override-cap-setting.d.ts +180 -0
- package/dist/security/override-cap-setting.d.ts.map +1 -0
- package/dist/security/override-cap-setting.js +657 -0
- package/dist/security/override-cap-setting.js.map +1 -0
- package/dist/security/override-cap.d.ts +20 -29
- package/dist/security/override-cap.d.ts.map +1 -1
- package/dist/security/override-cap.js +47 -71
- package/dist/security/override-cap.js.map +1 -1
- package/dist/security/owner-override-auth.d.ts +58 -0
- package/dist/security/owner-override-auth.d.ts.map +1 -1
- package/dist/security/owner-override-auth.js +82 -1
- package/dist/security/owner-override-auth.js.map +1 -1
- package/dist/security/owner-override-authority.d.ts +67 -0
- package/dist/security/owner-override-authority.d.ts.map +1 -0
- package/dist/security/owner-override-authority.js +100 -0
- package/dist/security/owner-override-authority.js.map +1 -0
- package/dist/sessions/handoff.d.ts.map +1 -1
- package/dist/sessions/handoff.js +4 -5
- package/dist/sessions/handoff.js.map +1 -1
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/index.js +22 -25
- package/dist/sessions/index.js.map +1 -1
- package/dist/store/conduit-doc-history.d.ts +39 -7
- package/dist/store/conduit-doc-history.d.ts.map +1 -1
- package/dist/store/conduit-doc-history.js +62 -12
- package/dist/store/conduit-doc-history.js.map +1 -1
- package/dist/store/data-safety-central.d.ts.map +1 -1
- package/dist/store/data-safety-central.js +3 -2
- package/dist/store/data-safety-central.js.map +1 -1
- package/dist/store/json.d.ts.map +1 -1
- package/dist/store/json.js +7 -7
- package/dist/store/json.js.map +1 -1
- package/dist/store/portable-bundle-scan.d.ts +23 -1
- package/dist/store/portable-bundle-scan.d.ts.map +1 -1
- package/dist/store/portable-bundle-scan.js +85 -3
- package/dist/store/portable-bundle-scan.js.map +1 -1
- package/dist/store/portable-bundle.d.ts.map +1 -1
- package/dist/store/portable-bundle.js +6 -1
- package/dist/store/portable-bundle.js.map +1 -1
- package/dist/store/row-identity-registry.d.ts +5 -3
- package/dist/store/row-identity-registry.d.ts.map +1 -1
- package/dist/store/row-identity-registry.js +54 -38
- package/dist/store/row-identity-registry.js.map +1 -1
- package/dist/store/schema/cleo-project/conduit.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/conduit.js +4 -3
- package/dist/store/schema/cleo-project/conduit.js.map +1 -1
- package/dist/store/schema/cleo-project/runtime.d.ts +32 -0
- package/dist/store/schema/cleo-project/runtime.d.ts.map +1 -1
- package/dist/store/schema/cleo-project/runtime.js +7 -3
- package/dist/store/schema/cleo-project/runtime.js.map +1 -1
- package/dist/store/session-store.d.ts +45 -0
- package/dist/store/session-store.d.ts.map +1 -1
- package/dist/store/session-store.js +96 -1
- package/dist/store/session-store.js.map +1 -1
- package/dist/store/sqlite.d.ts +2 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +12 -0
- package/dist/store/sqlite.js.map +1 -1
- package/dist/store/sync/merge/rules.d.ts.map +1 -1
- package/dist/store/sync/merge/rules.js +0 -16
- package/dist/store/sync/merge/rules.js.map +1 -1
- package/dist/store/sync/sealer.d.ts.map +1 -1
- package/dist/store/sync/sealer.js +27 -1
- package/dist/store/sync/sealer.js.map +1 -1
- package/dist/store/sync/sequencing.d.ts +3 -1
- package/dist/store/sync/sequencing.d.ts.map +1 -1
- package/dist/store/sync/sequencing.js +5 -3
- package/dist/store/sync/sequencing.js.map +1 -1
- package/dist/store/sync/timestamp-canon.d.ts.map +1 -1
- package/dist/store/sync/timestamp-canon.js +4 -0
- package/dist/store/sync/timestamp-canon.js.map +1 -1
- package/dist/store/table-classification.d.ts.map +1 -1
- package/dist/store/table-classification.js +12 -8
- package/dist/store/table-classification.js.map +1 -1
- package/dist/store/twin-collapse.d.ts +21 -0
- package/dist/store/twin-collapse.d.ts.map +1 -1
- package/dist/store/twin-collapse.js +235 -18
- package/dist/store/twin-collapse.js.map +1 -1
- package/dist/system/health.d.ts +17 -0
- package/dist/system/health.d.ts.map +1 -1
- package/dist/system/health.js +96 -0
- package/dist/system/health.js.map +1 -1
- package/dist/tasks/affected-packages.d.ts +67 -2
- package/dist/tasks/affected-packages.d.ts.map +1 -1
- package/dist/tasks/affected-packages.js +149 -21
- package/dist/tasks/affected-packages.js.map +1 -1
- package/dist/tasks/affected-scope.d.ts.map +1 -1
- package/dist/tasks/affected-scope.js +91 -5
- package/dist/tasks/affected-scope.js.map +1 -1
- package/dist/tasks/changeset-plan.d.ts +33 -0
- package/dist/tasks/changeset-plan.d.ts.map +1 -0
- package/dist/tasks/changeset-plan.js +152 -0
- package/dist/tasks/changeset-plan.js.map +1 -0
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +9 -1
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/done-plan.d.ts.map +1 -1
- package/dist/tasks/done-plan.js +28 -14
- package/dist/tasks/done-plan.js.map +1 -1
- package/dist/tasks/done-record.d.ts.map +1 -1
- package/dist/tasks/done-record.js +5 -1
- package/dist/tasks/done-record.js.map +1 -1
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +149 -18
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/gate-runner.d.ts.map +1 -1
- package/dist/tasks/gate-runner.js +76 -4
- package/dist/tasks/gate-runner.js.map +1 -1
- package/dist/tasks/qa-run-binding.d.ts +18 -0
- package/dist/tasks/qa-run-binding.d.ts.map +1 -1
- package/dist/tasks/qa-run-binding.js +41 -1
- package/dist/tasks/qa-run-binding.js.map +1 -1
- package/dist/tasks/test-run-binding.d.ts +62 -0
- package/dist/tasks/test-run-binding.d.ts.map +1 -1
- package/dist/tasks/test-run-binding.js +126 -21
- package/dist/tasks/test-run-binding.js.map +1 -1
- package/dist/tasks/tool-resolver.d.ts +7 -0
- package/dist/tasks/tool-resolver.d.ts.map +1 -1
- package/dist/tasks/tool-resolver.js +3 -1
- package/dist/tasks/tool-resolver.js.map +1 -1
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +24 -29
- package/dist/validation/engine-ops.js.map +1 -1
- package/migrations/drizzle-cleo-project/20261016120000_t12535-playbook-row-uids/migration.sql +24 -0
- package/package.json +13 -13
- package/schemas/project-context.schema.json +5 -0
- package/templates/CLEO-INJECTION.md +1 -1
- package/templates/CLEO-REFERENCE.md +42 -1
- package/templates/cleo-gitignore +3 -0
- package/templates/git-hooks/legacy-hashes.json +1 -0
- package/templates/git-hooks/pre-push +35 -7
- package/templates/workflows/release-prepare.yml.tmpl +82 -4
- package/dist/goal/advance-with-persist.d.ts +0 -80
- package/dist/goal/advance-with-persist.d.ts.map +0 -1
- package/dist/goal/advance-with-persist.js +0 -105
- package/dist/goal/advance-with-persist.js.map +0 -1
- package/dist/goal/arm.d.ts +0 -61
- package/dist/goal/arm.d.ts.map +0 -1
- package/dist/goal/arm.js +0 -65
- package/dist/goal/arm.js.map +0 -1
- package/dist/goal/continuation.d.ts +0 -66
- package/dist/goal/continuation.d.ts.map +0 -1
- package/dist/goal/continuation.js +0 -111
- package/dist/goal/continuation.js.map +0 -1
- package/dist/goal/judge.d.ts +0 -113
- package/dist/goal/judge.d.ts.map +0 -1
- package/dist/goal/judge.js +0 -193
- package/dist/goal/judge.js.map +0 -1
- package/dist/goal/loop.d.ts +0 -75
- package/dist/goal/loop.d.ts.map +0 -1
- package/dist/goal/loop.js +0 -132
- package/dist/goal/loop.js.map +0 -1
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-project owner override cap setting (T13787, owner decision
|
|
3
|
+
* 2026-10-16): `evidence.overrideCapPerSession` replaces the fixed cap of 10
|
|
4
|
+
* per session, and ONLY the owner may change it.
|
|
5
|
+
*
|
|
6
|
+
* ## Threat model
|
|
7
|
+
*
|
|
8
|
+
* An agent running as the same OS user can rewrite any local file, so no
|
|
9
|
+
* local check is tamper-proof. The goal is narrower and achievable: an agent
|
|
10
|
+
* cannot raise the cap through the supported path, and cannot raise it
|
|
11
|
+
* silently. Tampering is detectable, not preventable.
|
|
12
|
+
*
|
|
13
|
+
* ## Enrollment (explicit, owner-only, anchored twice)
|
|
14
|
+
*
|
|
15
|
+
* `cleo config owner-enroll` (never a side effect of a set) derives an Ed25519
|
|
16
|
+
* key from the owner-auth password (scrypt over a fresh per-project salt) and
|
|
17
|
+
* records its public key in two places a single file edit cannot reset:
|
|
18
|
+
* - `.cleo/owner-settings.json`, which is tracked in git like
|
|
19
|
+
* `.cleo/project.json` (ADR-094), so a delete or forge shows in
|
|
20
|
+
* `git status` and PR diffs;
|
|
21
|
+
* - the global store (`<cleoHome>/owner-enrollments.json`), keyed by project
|
|
22
|
+
* id, with when, which session and which terminal enrolled it.
|
|
23
|
+
* Re-enrolling over an existing record needs `--reset`, which is audited.
|
|
24
|
+
* `cleo doctor` reports the enrollment and any mismatch.
|
|
25
|
+
*
|
|
26
|
+
* ## Who may enroll or set
|
|
27
|
+
*
|
|
28
|
+
* T1118 L4 as built: not a restricted agent role, stdin and stderr a TTY, and
|
|
29
|
+
* the password typed now matching the bound session's owner-auth verifier
|
|
30
|
+
* (`cleo session start --scope global --name owner --owner-auth`). A pty
|
|
31
|
+
* (`script`) passes the TTY check, so an agent CAN enroll in a project that has
|
|
32
|
+
* no enrollment yet — which is why enrollment is explicit, recorded with its
|
|
33
|
+
* terminal, and shown by `cleo doctor` and git.
|
|
34
|
+
*
|
|
35
|
+
* ## Reading
|
|
36
|
+
*
|
|
37
|
+
* The cap is the sealed value only when the project file and the global record
|
|
38
|
+
* agree on the key and the seal verifies. A missing or unreadable file with a
|
|
39
|
+
* global record, a file with no global record, a key mismatch or a bad seal
|
|
40
|
+
* each fall back to {@link DEFAULT_OVERRIDE_CAP_PER_SESSION}, with a warning and
|
|
41
|
+
* an audit line in `.cleo/audit/force-bypass.jsonl`. Every set and every
|
|
42
|
+
* refused attempt is audited there with the old and new value. Who may use
|
|
43
|
+
* `CLEO_OWNER_OVERRIDE=1` at all is decided by `owner-override-authority.ts`
|
|
44
|
+
* (T13790).
|
|
45
|
+
*
|
|
46
|
+
* @task T13787
|
|
47
|
+
*/
|
|
48
|
+
import { execFileSync } from 'node:child_process';
|
|
49
|
+
import { createHash, randomBytes, scryptSync } from 'node:crypto';
|
|
50
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
51
|
+
import { join } from 'node:path';
|
|
52
|
+
import { BRANCH_LOCK_ERROR_CODES } from '@cleocode/contracts/branch-lock.js';
|
|
53
|
+
import { readPortableProjectId } from '@cleocode/paths';
|
|
54
|
+
import { getCleoHome } from '../paths.js';
|
|
55
|
+
import { atomicWriteJson } from '../store/atomic.js';
|
|
56
|
+
import { DEFAULT_OVERRIDE_CAP_PER_SESSION, MAX_OVERRIDE_CAP_PER_SESSION, OVERRIDE_CAP_OWNER_COMMAND, OVERRIDE_CAP_SETTING, } from './override-cap.js';
|
|
57
|
+
import { appendOwnerOverrideAudit, isTtyPresent, OWNER_SCRYPT_PARAMS, OWNER_SESSION_MARK, validateOwnerOverride, verifyOwnerAuthPassword, } from './owner-override-auth.js';
|
|
58
|
+
/** The explicit owner enrollment command, named in every refusal. */
|
|
59
|
+
export const OWNER_ENROLL_COMMAND = 'cleo config owner-enroll';
|
|
60
|
+
/** How the owner runs {@link OVERRIDE_CAP_OWNER_COMMAND}. */
|
|
61
|
+
const OWNER_HOW = 'run it yourself in an interactive terminal, in a session started with `cleo session start --scope global --name owner --owner-auth`';
|
|
62
|
+
/** An enrollment younger than this is reported as a doctor WARN. */
|
|
63
|
+
const RECENT_ENROLLMENT_MS = 7 * 24 * 60 * 60 * 1000;
|
|
64
|
+
/** Parent commands that wrap a fake terminal around a process. */
|
|
65
|
+
const PTY_WRAPPERS = /(^|\/)(script|expect|unbuffer)$/;
|
|
66
|
+
/** Git's view of the project file, as `cleo doctor project-identity` checks identity files. */
|
|
67
|
+
function trackingWarnings(projectRoot) {
|
|
68
|
+
const rel = '.cleo/owner-settings.json';
|
|
69
|
+
const git = (args) => {
|
|
70
|
+
try {
|
|
71
|
+
const out = execFileSync('git', args, {
|
|
72
|
+
cwd: projectRoot,
|
|
73
|
+
encoding: 'utf-8',
|
|
74
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
});
|
|
76
|
+
return { ok: true, out };
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return { ok: false, out: '' };
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
if (!git(['rev-parse', '--is-inside-work-tree']).ok)
|
|
83
|
+
return [];
|
|
84
|
+
if (git(['check-ignore', '-q', '--', rel]).ok)
|
|
85
|
+
return [
|
|
86
|
+
`${rel} is git-ignored, so a delete or forge would not show in git; add \`!owner-settings.json\` to .cleo/.gitignore (cleo upgrade) and commit it`,
|
|
87
|
+
];
|
|
88
|
+
if (!git(['ls-files', '--error-unmatch', '--', rel]).ok)
|
|
89
|
+
return [
|
|
90
|
+
`${rel} is not tracked in git; commit it so a delete or forge shows in git status and PR diffs`,
|
|
91
|
+
];
|
|
92
|
+
if (git(['status', '--porcelain', '--', rel]).out.trim() !== '')
|
|
93
|
+
return [
|
|
94
|
+
`${rel} has uncommitted changes; review and commit them (or restore it from git if you did not make them)`,
|
|
95
|
+
];
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What git history says about an earlier enrollment (T13829, review-2253):
|
|
100
|
+
* `present` when `.cleo/owner-settings.json` appears on any ref or in the
|
|
101
|
+
* reflog (a commit that deleted it, or a deleted ref, cannot hide it);
|
|
102
|
+
* `unavailable` when history cannot prove absence (a shallow clone, or git
|
|
103
|
+
* erroring inside a work tree); `none` only when it is provably absent or the
|
|
104
|
+
* project is not a git work tree at all.
|
|
105
|
+
*/
|
|
106
|
+
function settingsHistory(projectRoot) {
|
|
107
|
+
const git = (args) => {
|
|
108
|
+
try {
|
|
109
|
+
return execFileSync('git', args, {
|
|
110
|
+
cwd: projectRoot,
|
|
111
|
+
encoding: 'utf-8',
|
|
112
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
113
|
+
}).trim();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
if (git(['rev-parse', '--is-inside-work-tree']) !== 'true')
|
|
120
|
+
return 'none';
|
|
121
|
+
const shallow = git(['rev-parse', '--is-shallow-repository']);
|
|
122
|
+
if (shallow !== 'false')
|
|
123
|
+
return 'unavailable';
|
|
124
|
+
const log = git([
|
|
125
|
+
'log',
|
|
126
|
+
'--all',
|
|
127
|
+
'--reflog',
|
|
128
|
+
'--format=%H',
|
|
129
|
+
'-n',
|
|
130
|
+
'1',
|
|
131
|
+
'--',
|
|
132
|
+
'.cleo/owner-settings.json',
|
|
133
|
+
]);
|
|
134
|
+
if (log === null)
|
|
135
|
+
return 'unavailable';
|
|
136
|
+
return log === '' ? 'none' : 'present';
|
|
137
|
+
}
|
|
138
|
+
function settingsPath(projectRoot) {
|
|
139
|
+
return join(projectRoot, '.cleo', 'owner-settings.json');
|
|
140
|
+
}
|
|
141
|
+
function globalPath() {
|
|
142
|
+
return join(getCleoHome(), 'owner-enrollments.json');
|
|
143
|
+
}
|
|
144
|
+
/** The global record key: the portable project id, else the real path. */
|
|
145
|
+
function projectKey(projectRoot) {
|
|
146
|
+
const id = readPortableProjectId(projectRoot);
|
|
147
|
+
if (id.status === 'valid')
|
|
148
|
+
return id.projectId;
|
|
149
|
+
try {
|
|
150
|
+
return `path:${realpathSync(projectRoot)}`;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return `path:${projectRoot}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function fingerprintOf(pubkey) {
|
|
157
|
+
return createHash('sha256').update(pubkey).digest('hex').slice(0, 16);
|
|
158
|
+
}
|
|
159
|
+
/** The project file: absent, readable, or unreadable (garbage, no key). */
|
|
160
|
+
function readSettings(projectRoot) {
|
|
161
|
+
if (!existsSync(settingsPath(projectRoot)))
|
|
162
|
+
return { state: 'absent' };
|
|
163
|
+
try {
|
|
164
|
+
const parsed = JSON.parse(readFileSync(settingsPath(projectRoot), 'utf-8'));
|
|
165
|
+
if (typeof parsed.ownerPubkey !== 'string' || typeof parsed.salt !== 'string')
|
|
166
|
+
return { state: 'bad', why: 'it has no enrolled owner key' };
|
|
167
|
+
return {
|
|
168
|
+
state: 'read',
|
|
169
|
+
file: {
|
|
170
|
+
schemaVersion: 1,
|
|
171
|
+
ownerPubkey: parsed.ownerPubkey,
|
|
172
|
+
salt: parsed.salt,
|
|
173
|
+
settings: parsed.settings ?? {},
|
|
174
|
+
enrolledAt: parsed.enrolledAt ?? '',
|
|
175
|
+
sessionId: parsed.sessionId ?? null,
|
|
176
|
+
tty: parsed.tty ?? null,
|
|
177
|
+
parent: parsed.parent ?? null,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return { state: 'bad', why: err instanceof Error ? err.message : String(err) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function readGlobalAll() {
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(readFileSync(globalPath(), 'utf-8'));
|
|
188
|
+
return parsed.projects ?? {};
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return {};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function readGlobal(projectRoot) {
|
|
195
|
+
return readGlobalAll()[projectKey(projectRoot)] ?? null;
|
|
196
|
+
}
|
|
197
|
+
/** The exact bytes a seal signs. */
|
|
198
|
+
function sealPayload(key, value, setAt, salt, ownerPubkey) {
|
|
199
|
+
return JSON.stringify({ key, value, setAt, salt, ownerPubkey });
|
|
200
|
+
}
|
|
201
|
+
/** The owner's signing key: Ed25519 from scrypt(password, salt). */
|
|
202
|
+
async function ownerKey(password, salt) {
|
|
203
|
+
const { AgentIdentity } = await import('../identity/cleo-identity.js');
|
|
204
|
+
return AgentIdentity.fromSeed(new Uint8Array(scryptSync(password, Buffer.from(salt, 'hex'), 32, OWNER_SCRYPT_PARAMS)));
|
|
205
|
+
}
|
|
206
|
+
/** Best-effort terminal provenance of this process, for the enrollment record. */
|
|
207
|
+
function provenance(sessionId) {
|
|
208
|
+
const ps = (args) => {
|
|
209
|
+
try {
|
|
210
|
+
return (execFileSync('ps', args, {
|
|
211
|
+
encoding: 'utf-8',
|
|
212
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
213
|
+
}).trim() || null);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
enrolledAt: new Date().toISOString(),
|
|
221
|
+
sessionId,
|
|
222
|
+
tty: ps(['-o', 'tty=', '-p', String(process.pid)]),
|
|
223
|
+
parent: ps(['-o', 'comm=', '-p', String(process.ppid)]),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function audit(projectRoot, action, fields) {
|
|
227
|
+
appendOwnerOverrideAudit(projectRoot, {
|
|
228
|
+
timestamp: new Date().toISOString(),
|
|
229
|
+
taskId: '*',
|
|
230
|
+
gate: OVERRIDE_CAP_SETTING,
|
|
231
|
+
action,
|
|
232
|
+
agent_id: process.env['CLEO_AGENT_ID'] ?? 'unknown',
|
|
233
|
+
session_id: fields.sessionId,
|
|
234
|
+
token_validated: fields.ok === true,
|
|
235
|
+
tty_present: isTtyPresent(),
|
|
236
|
+
override_count: 0,
|
|
237
|
+
reason: fields.reason,
|
|
238
|
+
pid: process.pid,
|
|
239
|
+
setting: OVERRIDE_CAP_SETTING,
|
|
240
|
+
...(fields.old !== undefined ? { old_value: fields.old } : {}),
|
|
241
|
+
...(fields.next !== undefined ? { new_value: fields.next } : {}),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The project's owner state, judged without auditing: the sealed cap, or why
|
|
246
|
+
* stored state is ignored.
|
|
247
|
+
*/
|
|
248
|
+
async function judgeOwnerState(projectRoot) {
|
|
249
|
+
const read = readSettings(projectRoot);
|
|
250
|
+
const global = readGlobal(projectRoot);
|
|
251
|
+
if (read.state === 'absent')
|
|
252
|
+
return global
|
|
253
|
+
? {
|
|
254
|
+
kind: 'tampered',
|
|
255
|
+
why: `.cleo/owner-settings.json is missing, but the global store records owner key ${global.fingerprint} for this project (deleted? restore it from git, or the owner re-enrolls with \`${OWNER_ENROLL_COMMAND} --reset\`)`,
|
|
256
|
+
}
|
|
257
|
+
: { kind: 'none' };
|
|
258
|
+
if (read.state === 'bad')
|
|
259
|
+
return {
|
|
260
|
+
kind: 'tampered',
|
|
261
|
+
why: `.cleo/owner-settings.json is unreadable (${read.why}); restore it from git, or the owner re-enrolls with \`${OWNER_ENROLL_COMMAND} --reset\``,
|
|
262
|
+
};
|
|
263
|
+
const file = read.file;
|
|
264
|
+
const fp = fingerprintOf(file.ownerPubkey);
|
|
265
|
+
if (!global)
|
|
266
|
+
return {
|
|
267
|
+
kind: 'tampered',
|
|
268
|
+
why: `.cleo/owner-settings.json names owner key ${fp}, which the global store never enrolled for this project (forged or copied in?)`,
|
|
269
|
+
};
|
|
270
|
+
if (global.ownerPubkey !== file.ownerPubkey)
|
|
271
|
+
return {
|
|
272
|
+
kind: 'tampered',
|
|
273
|
+
why: `.cleo/owner-settings.json names owner key ${fp}, but the global store enrolled ${global.fingerprint} for this project`,
|
|
274
|
+
};
|
|
275
|
+
const entry = file.settings[OVERRIDE_CAP_SETTING];
|
|
276
|
+
if (!entry)
|
|
277
|
+
return { kind: 'ok', file, global, cap: null };
|
|
278
|
+
const { verifyAuditLine } = await import('../identity/cleo-identity.js');
|
|
279
|
+
const valid = Number.isInteger(entry.value) &&
|
|
280
|
+
entry.value >= 1 &&
|
|
281
|
+
entry.value <= MAX_OVERRIDE_CAP_PER_SESSION &&
|
|
282
|
+
typeof entry.sig === 'string' &&
|
|
283
|
+
(await verifyAuditLine(sealPayload(OVERRIDE_CAP_SETTING, entry.value, entry.setAt, file.salt, file.ownerPubkey), entry.sig, file.ownerPubkey));
|
|
284
|
+
if (!valid)
|
|
285
|
+
return {
|
|
286
|
+
kind: 'tampered',
|
|
287
|
+
why: `${OVERRIDE_CAP_SETTING} in .cleo/owner-settings.json is not sealed by the enrolled owner key (edited by hand?)`,
|
|
288
|
+
};
|
|
289
|
+
return { kind: 'ok', file, global, cap: entry.value };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The per-session override cap in force for a project: the owner's sealed
|
|
293
|
+
* `evidence.overrideCapPerSession`, or {@link DEFAULT_OVERRIDE_CAP_PER_SESSION}.
|
|
294
|
+
* Stored state that is missing, unanchored, mismatched or unsealed is ignored,
|
|
295
|
+
* warned about and audited (see the module doc).
|
|
296
|
+
*
|
|
297
|
+
* @param projectRoot - Project root.
|
|
298
|
+
* @returns The cap, its source, and a warning when stored state was ignored.
|
|
299
|
+
* @task T13787
|
|
300
|
+
*/
|
|
301
|
+
export async function resolveOverrideCap(projectRoot) {
|
|
302
|
+
const state = await judgeOwnerState(projectRoot);
|
|
303
|
+
// T13829: an enrollment that replaced another without proof of the previous
|
|
304
|
+
// owner sets no cap until the owner confirms it.
|
|
305
|
+
if (state.kind === 'ok' && state.global.resetFrom) {
|
|
306
|
+
const warning = `owner key ${state.global.fingerprint} replaced an earlier enrollment without proof of the previous owner (${state.global.resetFrom}); ${OVERRIDE_CAP_SETTING} falls back to ${DEFAULT_OVERRIDE_CAP_PER_SESSION} until \`${OWNER_ENROLL_COMMAND} --confirm\`.`;
|
|
307
|
+
// Audited once per unconfirmed state, not on every read.
|
|
308
|
+
const stateKey = `${state.global.fingerprint}:${state.global.resetFrom}:${state.global.enrolledAt}`;
|
|
309
|
+
if (state.global.unconfirmedAudited !== stateKey) {
|
|
310
|
+
audit(projectRoot, 'owner-settings-unconfirmed', { sessionId: null, reason: warning });
|
|
311
|
+
await atomicWriteJson(globalPath(), {
|
|
312
|
+
schemaVersion: 1,
|
|
313
|
+
projects: {
|
|
314
|
+
...readGlobalAll(),
|
|
315
|
+
[projectKey(projectRoot)]: { ...state.global, unconfirmedAudited: stateKey },
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
return { cap: DEFAULT_OVERRIDE_CAP_PER_SESSION, source: 'default', warning };
|
|
320
|
+
}
|
|
321
|
+
if (state.kind === 'ok' && state.cap !== null)
|
|
322
|
+
return { cap: state.cap, source: 'owner-setting' };
|
|
323
|
+
if (state.kind !== 'tampered')
|
|
324
|
+
return { cap: DEFAULT_OVERRIDE_CAP_PER_SESSION, source: 'default' };
|
|
325
|
+
const warning = `${state.why}; ${OVERRIDE_CAP_SETTING} falls back to ${DEFAULT_OVERRIDE_CAP_PER_SESSION}.`;
|
|
326
|
+
audit(projectRoot, 'owner-settings-invalid', { sessionId: null, reason: warning });
|
|
327
|
+
return { cap: DEFAULT_OVERRIDE_CAP_PER_SESSION, source: 'default', warning };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* The project's owner enrollment for `cleo doctor`: none, enrolled (with
|
|
331
|
+
* fingerprint, when, which session and terminal), or tampered (why).
|
|
332
|
+
*
|
|
333
|
+
* @param projectRoot - Project root.
|
|
334
|
+
* @returns The status, with the cap in force.
|
|
335
|
+
* @task T13787
|
|
336
|
+
*/
|
|
337
|
+
export async function ownerEnrollmentStatus(projectRoot) {
|
|
338
|
+
const state = await judgeOwnerState(projectRoot);
|
|
339
|
+
if (state.kind === 'none')
|
|
340
|
+
return {
|
|
341
|
+
status: 'none',
|
|
342
|
+
message: `No owner is enrolled for this project; ${OVERRIDE_CAP_SETTING} is ${DEFAULT_OVERRIDE_CAP_PER_SESSION}.`,
|
|
343
|
+
cap: DEFAULT_OVERRIDE_CAP_PER_SESSION,
|
|
344
|
+
warnings: [],
|
|
345
|
+
};
|
|
346
|
+
if (state.kind === 'tampered')
|
|
347
|
+
return {
|
|
348
|
+
status: 'tampered',
|
|
349
|
+
message: `Owner settings ignored: ${state.why}. ${OVERRIDE_CAP_SETTING} is ${DEFAULT_OVERRIDE_CAP_PER_SESSION}.`,
|
|
350
|
+
cap: DEFAULT_OVERRIDE_CAP_PER_SESSION,
|
|
351
|
+
warnings: [],
|
|
352
|
+
};
|
|
353
|
+
const g = state.global;
|
|
354
|
+
const cap = state.cap ?? DEFAULT_OVERRIDE_CAP_PER_SESSION;
|
|
355
|
+
if (g.resetFrom)
|
|
356
|
+
return {
|
|
357
|
+
status: 'unconfirmed',
|
|
358
|
+
message: `Owner key ${g.fingerprint} replaced an earlier enrollment WITHOUT proof of the previous owner ` +
|
|
359
|
+
`(${g.resetFrom}) at ${g.enrolledAt}, by session ${g.sessionId ?? 'unknown'} on tty ${g.tty ?? 'unknown'} ` +
|
|
360
|
+
`(parent ${g.parent ?? 'unknown'}); ${OVERRIDE_CAP_SETTING} stays ${DEFAULT_OVERRIDE_CAP_PER_SESSION} until confirmed. If you did this, confirm it with ` +
|
|
361
|
+
`\`${OWNER_ENROLL_COMMAND} --confirm\`; if not, restore .cleo/owner-settings.json from git and re-enroll. ` +
|
|
362
|
+
'An agent that did the reset could also confirm it: this is visibility, not prevention.',
|
|
363
|
+
fingerprint: g.fingerprint,
|
|
364
|
+
enrolledAt: g.enrolledAt,
|
|
365
|
+
sessionId: g.sessionId,
|
|
366
|
+
tty: g.tty,
|
|
367
|
+
parent: g.parent,
|
|
368
|
+
cap: DEFAULT_OVERRIDE_CAP_PER_SESSION,
|
|
369
|
+
warnings: [],
|
|
370
|
+
};
|
|
371
|
+
const enrolledMs = Date.parse(g.enrolledAt);
|
|
372
|
+
const warnings = [
|
|
373
|
+
...(Number.isFinite(enrolledMs) && Date.now() - enrolledMs < RECENT_ENROLLMENT_MS
|
|
374
|
+
? [
|
|
375
|
+
`owner key ${g.fingerprint} was enrolled or reset recently (${g.enrolledAt}, session ${g.sessionId ?? 'unknown'}, tty ${g.tty ?? 'unknown'}, parent ${g.parent ?? 'unknown'}); confirm you did it`,
|
|
376
|
+
]
|
|
377
|
+
: []),
|
|
378
|
+
...(g.parent && PTY_WRAPPERS.test(g.parent)
|
|
379
|
+
? [`the enrolling process ran under ${g.parent}, which fakes a terminal; confirm you did it`]
|
|
380
|
+
: []),
|
|
381
|
+
...trackingWarnings(projectRoot),
|
|
382
|
+
];
|
|
383
|
+
return {
|
|
384
|
+
warnings,
|
|
385
|
+
status: 'enrolled',
|
|
386
|
+
message: `Owner key ${g.fingerprint} enrolled ${g.enrolledAt} by session ${g.sessionId ?? 'unknown'} ` +
|
|
387
|
+
`on tty ${g.tty ?? 'unknown'} (parent ${g.parent ?? 'unknown'}); ${OVERRIDE_CAP_SETTING} = ${cap}. ` +
|
|
388
|
+
'If you did not enroll it, run `cleo config owner-enroll --reset` yourself.',
|
|
389
|
+
fingerprint: g.fingerprint,
|
|
390
|
+
enrolledAt: g.enrolledAt,
|
|
391
|
+
sessionId: g.sessionId,
|
|
392
|
+
tty: g.tty,
|
|
393
|
+
parent: g.parent,
|
|
394
|
+
cap,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/** T1118 L4: role fence, TTY, and the password against the session's verifier. */
|
|
398
|
+
function ownerCheck(caller) {
|
|
399
|
+
const token = caller.session?.token ?? null;
|
|
400
|
+
if (token === null)
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
code: BRANCH_LOCK_ERROR_CODES.E_OVERRIDE_TOKEN_INVALID,
|
|
404
|
+
message: 'The bound session has no owner-auth token.',
|
|
405
|
+
};
|
|
406
|
+
// Review-2253 round 2: only a scrypt verifier vouches for the owner; a
|
|
407
|
+
// legacy HMAC token is a cheap offline oracle and is never accepted here.
|
|
408
|
+
const verifier = token.startsWith(`${OWNER_SESSION_MARK}|`)
|
|
409
|
+
? token.slice(OWNER_SESSION_MARK.length + 1)
|
|
410
|
+
: token;
|
|
411
|
+
if (!verifier.startsWith('scrypt$v1$'))
|
|
412
|
+
return {
|
|
413
|
+
ok: false,
|
|
414
|
+
code: BRANCH_LOCK_ERROR_CODES.E_OVERRIDE_TOKEN_INVALID,
|
|
415
|
+
message: 'The bound session has no scrypt owner-auth verifier (it predates T13787 or was started with --owner alone); start a new --owner-auth session.',
|
|
416
|
+
};
|
|
417
|
+
const auth = validateOwnerOverride({
|
|
418
|
+
sessionId: caller.session?.id ?? 'global',
|
|
419
|
+
skipTokenCheck: true,
|
|
420
|
+
});
|
|
421
|
+
if (!auth.allowed)
|
|
422
|
+
return {
|
|
423
|
+
ok: false,
|
|
424
|
+
code: auth.errorCode ?? 'E_OVERRIDE_REFUSED',
|
|
425
|
+
message: auth.errorMessage ?? 'Refused.',
|
|
426
|
+
};
|
|
427
|
+
if (!verifyOwnerAuthPassword(caller.password, token, caller.session?.name ?? 'session'))
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
code: BRANCH_LOCK_ERROR_CODES.E_OVERRIDE_TOKEN_INVALID,
|
|
431
|
+
message: "The password does not match the bound session's owner-auth token.",
|
|
432
|
+
};
|
|
433
|
+
return { ok: true };
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Refuse, and audit, a write of `evidence.overrideCapPerSession` that did not
|
|
437
|
+
* come through {@link setOwnerOverrideCap} — a plain config write (T13787).
|
|
438
|
+
*
|
|
439
|
+
* @param projectRoot - Project root.
|
|
440
|
+
* @param value - The value the write asked for.
|
|
441
|
+
* @returns The refusal message, naming the owner command.
|
|
442
|
+
* @task T13787
|
|
443
|
+
*/
|
|
444
|
+
export async function refuseOverrideCapWrite(projectRoot, value) {
|
|
445
|
+
const previous = (await resolveOverrideCap(projectRoot)).cap;
|
|
446
|
+
const message = `${OVERRIDE_CAP_SETTING} is owner-only and is never written to config.json. Only the owner can change it, with \`${OVERRIDE_CAP_OWNER_COMMAND}\`: ${OWNER_HOW}.`;
|
|
447
|
+
audit(projectRoot, 'override-cap-set-refused', {
|
|
448
|
+
sessionId: null,
|
|
449
|
+
old: previous,
|
|
450
|
+
...(typeof value === 'number' ? { next: value } : {}),
|
|
451
|
+
reason: 'plain config write refused',
|
|
452
|
+
});
|
|
453
|
+
return message;
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Enroll the owner key for this project — explicitly, never as a side effect
|
|
457
|
+
* of a set (T13787 review). Refused, and audited, unless the caller passes
|
|
458
|
+
* T1118 L4; refused over an existing enrollment (project file or global
|
|
459
|
+
* record) unless `reset`, which is audited loudly and drops the old seals.
|
|
460
|
+
*
|
|
461
|
+
* @param projectRoot - Project root.
|
|
462
|
+
* @param caller - The password typed at the TTY and the bound session.
|
|
463
|
+
* @param opts - `reset`: replace an existing enrollment.
|
|
464
|
+
* @returns The new key's fingerprint, or why enrollment was refused.
|
|
465
|
+
* @task T13787
|
|
466
|
+
*/
|
|
467
|
+
export async function enrollOwner(projectRoot, caller, opts = {}) {
|
|
468
|
+
const sessionId = caller.session?.id ?? null;
|
|
469
|
+
const refuse = (code, message) => {
|
|
470
|
+
audit(projectRoot, 'owner-enroll-refused', { sessionId, reason: message });
|
|
471
|
+
return { ok: false, code, message: `${message} Only the owner can enroll: ${OWNER_HOW}.` };
|
|
472
|
+
};
|
|
473
|
+
const check = ownerCheck(caller);
|
|
474
|
+
if (!check.ok)
|
|
475
|
+
return refuse(check.code, check.message);
|
|
476
|
+
const existing = readGlobal(projectRoot);
|
|
477
|
+
const fileThere = readSettings(projectRoot).state !== 'absent';
|
|
478
|
+
if ((existing || fileThere) && opts.reset !== true)
|
|
479
|
+
return refuse(
|
|
480
|
+
// @sync-invariant none:local-only owner enrollment state is device-local and git-tracked; refused before any write
|
|
481
|
+
'E_OWNER_ALREADY_ENROLLED', `An owner is already enrolled for this project${existing ? ` (key ${existing.fingerprint})` : ''}; re-enrolling needs \`${OWNER_ENROLL_COMMAND} --reset\`.`);
|
|
482
|
+
// Review-2253 rounds 2-3: while the global record is intact, a reset must
|
|
483
|
+
// prove the old owner's password against it — even with the project file
|
|
484
|
+
// deleted. Only with both anchors gone (the global one outside the repo) is
|
|
485
|
+
// a reset password-free, and then it is recorded as unconfirmed.
|
|
486
|
+
let resetFrom;
|
|
487
|
+
if (opts.reset === true) {
|
|
488
|
+
if (existing) {
|
|
489
|
+
const read = readSettings(projectRoot);
|
|
490
|
+
const oldSalt = existing.salt ??
|
|
491
|
+
(read.state === 'read' && read.file.ownerPubkey === existing.ownerPubkey
|
|
492
|
+
? read.file.salt
|
|
493
|
+
: undefined);
|
|
494
|
+
const proven = oldSalt !== undefined &&
|
|
495
|
+
(await ownerKey(caller.password, oldSalt)).pubkeyHex === existing.ownerPubkey;
|
|
496
|
+
if (!proven)
|
|
497
|
+
return refuse(
|
|
498
|
+
// @sync-invariant none:local-only owner enrollment state is device-local and git-tracked; refused before any write
|
|
499
|
+
'E_OWNER_RESET_NOT_OWNER', `Resetting needs the current owner's password (key ${existing.fingerprint}, recorded in the global owner-enrollments.json). If it is lost, remove BOTH anchors deliberately (.cleo/owner-settings.json and this project's record in the global owner-enrollments.json, outside the repo) and enroll again.`);
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
resetFrom = fileThere ? 'tampered' : 'fresh-after-removal';
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
else if (!existing && !fileThere) {
|
|
506
|
+
// Both anchors gone: git history decides whether a plain enroll replaces an
|
|
507
|
+
// owner it never proved (present), or cannot tell (unavailable).
|
|
508
|
+
const history = settingsHistory(projectRoot);
|
|
509
|
+
if (history === 'present')
|
|
510
|
+
resetFrom = 'fresh-after-removal';
|
|
511
|
+
else if (history === 'unavailable')
|
|
512
|
+
resetFrom = 'history-unavailable';
|
|
513
|
+
}
|
|
514
|
+
const salt = randomBytes(16).toString('hex');
|
|
515
|
+
const key = await ownerKey(caller.password, salt);
|
|
516
|
+
const prov = provenance(sessionId);
|
|
517
|
+
const fingerprint = fingerprintOf(key.pubkeyHex);
|
|
518
|
+
const file = {
|
|
519
|
+
schemaVersion: 1,
|
|
520
|
+
ownerPubkey: key.pubkeyHex,
|
|
521
|
+
salt,
|
|
522
|
+
settings: {},
|
|
523
|
+
...prov,
|
|
524
|
+
};
|
|
525
|
+
await atomicWriteJson(settingsPath(projectRoot), file);
|
|
526
|
+
await atomicWriteJson(globalPath(), {
|
|
527
|
+
schemaVersion: 1,
|
|
528
|
+
projects: {
|
|
529
|
+
...readGlobalAll(),
|
|
530
|
+
[projectKey(projectRoot)]: {
|
|
531
|
+
ownerPubkey: key.pubkeyHex,
|
|
532
|
+
fingerprint,
|
|
533
|
+
projectRoot,
|
|
534
|
+
salt,
|
|
535
|
+
...prov,
|
|
536
|
+
...(resetFrom ? { resetFrom } : {}),
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
audit(projectRoot, opts.reset === true ? 'owner-enroll-reset' : 'owner-enroll', {
|
|
541
|
+
sessionId,
|
|
542
|
+
ok: true,
|
|
543
|
+
reason: opts.reset === true
|
|
544
|
+
? `owner key RESET to ${fingerprint}${existing ? ` (was ${existing.fingerprint}, old password proven)` : ` without proof of a previous owner (${resetFrom})`}; earlier seals dropped (tty ${prov.tty ?? 'unknown'}, parent ${prov.parent ?? 'unknown'})`
|
|
545
|
+
: `owner key ${fingerprint} enrolled (tty ${prov.tty ?? 'unknown'}, parent ${prov.parent ?? 'unknown'})`,
|
|
546
|
+
});
|
|
547
|
+
return { ok: true, fingerprint, reset: opts.reset === true };
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Confirm an enrollment that replaced another without proof of the previous
|
|
551
|
+
* owner (review-2253 round 3): the owner proves the CURRENT key's password and
|
|
552
|
+
* the `resetFrom` mark that makes `cleo doctor` FAIL is cleared, audited. An
|
|
553
|
+
* agent that did the reset could confirm it too — visibility, not prevention.
|
|
554
|
+
*
|
|
555
|
+
* @param projectRoot - Project root.
|
|
556
|
+
* @param caller - The password typed at the TTY and the bound session.
|
|
557
|
+
* @returns The confirmed key's fingerprint, or why it was refused.
|
|
558
|
+
* @task T13787
|
|
559
|
+
*/
|
|
560
|
+
export async function confirmOwnerEnrollment(projectRoot, caller) {
|
|
561
|
+
const sessionId = caller.session?.id ?? null;
|
|
562
|
+
const refuse = (code, message) => {
|
|
563
|
+
audit(projectRoot, 'owner-enroll-confirm-refused', { sessionId, reason: message });
|
|
564
|
+
return { ok: false, code, message: `${message} Only the owner can confirm: ${OWNER_HOW}.` };
|
|
565
|
+
};
|
|
566
|
+
const check = ownerCheck(caller);
|
|
567
|
+
if (!check.ok)
|
|
568
|
+
return refuse(check.code, check.message);
|
|
569
|
+
const state = await judgeOwnerState(projectRoot);
|
|
570
|
+
if (state.kind !== 'ok')
|
|
571
|
+
return refuse(
|
|
572
|
+
// @sync-invariant none:local-only owner enrollment state is device-local and git-tracked; refused before any write
|
|
573
|
+
'E_OWNER_SETTINGS_TAMPERED', state.kind === 'tampered' ? `${state.why}.` : 'No owner is enrolled for this project.');
|
|
574
|
+
if ((await ownerKey(caller.password, state.file.salt)).pubkeyHex !== state.file.ownerPubkey)
|
|
575
|
+
return refuse(BRANCH_LOCK_ERROR_CODES.E_OVERRIDE_TOKEN_INVALID, 'The password does not match the owner key enrolled for this project.');
|
|
576
|
+
const { resetFrom: _cleared, ...rest } = state.global;
|
|
577
|
+
await atomicWriteJson(globalPath(), {
|
|
578
|
+
schemaVersion: 1,
|
|
579
|
+
projects: {
|
|
580
|
+
...readGlobalAll(),
|
|
581
|
+
[projectKey(projectRoot)]: { ...rest, confirmedAt: new Date().toISOString() },
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
audit(projectRoot, 'owner-enroll-confirm', {
|
|
585
|
+
sessionId,
|
|
586
|
+
ok: true,
|
|
587
|
+
reason: `owner confirmed key ${state.global.fingerprint}${state.global.resetFrom ? ` (was reset from ${state.global.resetFrom})` : ''}`,
|
|
588
|
+
});
|
|
589
|
+
return { ok: true, fingerprint: state.global.fingerprint, reset: false };
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Set `evidence.overrideCapPerSession` through the owner path: refused, and
|
|
593
|
+
* audited, unless the caller passes T1118 L4, the project is enrolled with its
|
|
594
|
+
* file and global record agreeing, and `password` derives the enrolled key.
|
|
595
|
+
* Never enrolls. Values above {@link MAX_OVERRIDE_CAP_PER_SESSION} are clamped.
|
|
596
|
+
*
|
|
597
|
+
* @param projectRoot - Project root.
|
|
598
|
+
* @param input - The requested value, the password typed at the TTY, and the
|
|
599
|
+
* bound session with its owner-auth verifier (null when it has none).
|
|
600
|
+
* @returns The new and previous cap, or why the set was refused.
|
|
601
|
+
* @task T13787
|
|
602
|
+
*/
|
|
603
|
+
export async function setOwnerOverrideCap(projectRoot, input) {
|
|
604
|
+
const previous = (await resolveOverrideCap(projectRoot)).cap;
|
|
605
|
+
const sessionId = input.session?.id ?? null;
|
|
606
|
+
const refuse = (code, message) => {
|
|
607
|
+
audit(projectRoot, 'override-cap-set-refused', {
|
|
608
|
+
sessionId,
|
|
609
|
+
old: previous,
|
|
610
|
+
next: input.value,
|
|
611
|
+
reason: message,
|
|
612
|
+
});
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
code,
|
|
616
|
+
message: `${message} Only the owner can change ${OVERRIDE_CAP_SETTING}: ${OWNER_HOW}.`,
|
|
617
|
+
};
|
|
618
|
+
};
|
|
619
|
+
if (!Number.isInteger(input.value) || input.value < 1)
|
|
620
|
+
// @sync-invariant none:input-shape the requested cap is not a positive integer
|
|
621
|
+
return refuse('E_VALIDATION', `${OVERRIDE_CAP_SETTING} must be a positive integer.`);
|
|
622
|
+
const check = ownerCheck(input);
|
|
623
|
+
if (!check.ok)
|
|
624
|
+
return refuse(check.code, check.message);
|
|
625
|
+
const read = readSettings(projectRoot);
|
|
626
|
+
const global = readGlobal(projectRoot);
|
|
627
|
+
if (read.state === 'absent' && !global)
|
|
628
|
+
return refuse(
|
|
629
|
+
// @sync-invariant none:local-only owner enrollment state is device-local and git-tracked; refused before any write
|
|
630
|
+
'E_OWNER_NOT_ENROLLED', `No owner is enrolled for this project; the owner enrolls first with \`${OWNER_ENROLL_COMMAND}\`.`);
|
|
631
|
+
const state = await judgeOwnerState(projectRoot);
|
|
632
|
+
if (state.kind !== 'ok')
|
|
633
|
+
return refuse(
|
|
634
|
+
// @sync-invariant none:local-only owner enrollment state is device-local and git-tracked; refused before any write
|
|
635
|
+
'E_OWNER_SETTINGS_TAMPERED', `${state.kind === 'tampered' ? state.why : 'owner settings are not usable'}.`);
|
|
636
|
+
const file = state.file;
|
|
637
|
+
const key = await ownerKey(input.password, file.salt);
|
|
638
|
+
if (key.pubkeyHex !== file.ownerPubkey)
|
|
639
|
+
return refuse(BRANCH_LOCK_ERROR_CODES.E_OVERRIDE_TOKEN_INVALID, 'The password does not match the owner key enrolled for this project.');
|
|
640
|
+
const value = Math.min(input.value, MAX_OVERRIDE_CAP_PER_SESSION);
|
|
641
|
+
const setAt = new Date().toISOString();
|
|
642
|
+
const { signAuditLine } = await import('../identity/cleo-identity.js');
|
|
643
|
+
const { sig } = await signAuditLine(key, sealPayload(OVERRIDE_CAP_SETTING, value, setAt, file.salt, file.ownerPubkey));
|
|
644
|
+
await atomicWriteJson(settingsPath(projectRoot), {
|
|
645
|
+
...file,
|
|
646
|
+
settings: { ...file.settings, [OVERRIDE_CAP_SETTING]: { value, setAt, sig } },
|
|
647
|
+
});
|
|
648
|
+
audit(projectRoot, 'override-cap-set', {
|
|
649
|
+
sessionId,
|
|
650
|
+
old: previous,
|
|
651
|
+
next: value,
|
|
652
|
+
ok: true,
|
|
653
|
+
reason: 'owner set',
|
|
654
|
+
});
|
|
655
|
+
return { ok: true, value, previous, clamped: value !== input.value };
|
|
656
|
+
}
|
|
657
|
+
//# sourceMappingURL=override-cap-setting.js.map
|