cli-aimlock 7.0.19 → 7.0.25

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/README.md CHANGED
@@ -1,14 +1,54 @@
1
1
  # cli-aimlock
2
2
 
3
- Aimlock — 智能目标 skill(CLI.Tax 发布)。
3
+ Aimlock — 面向大型、深度、跨模块、高风险、并行或用户明确要求的工程修改的智能目标门禁(CLI.Tax 发布)。
4
+
5
+ 低难度、预计不超过 500 行、非跨模块、非高风险、不并行且用户未明确要求 Aimlock 的需求不会启动完整链;应直接处理,或最多调用一个匹配专项技能。用户明确要求 Aimlock 时会激活门禁。
6
+
7
+ `run` 先在本地读取真实文件路径、最近 Git 改动规模、包边界和局部 import 图,推导目标文件、预计行数、跨模块和并行性;调用方自报值不会覆盖探测结果。信息模糊时从小档开始,超出实际预算后只逐级升级并继承已有快照和修改。bypass 不发起任何技能 HTTP 调用、不生成自动评价、不写需求文件。
8
+
9
+ Aimlock is for substantial or explicitly assigned engineering changes. Small low-difficulty work bypasses only when the user did not explicitly request Aimlock, and is handled directly or with one matched specialist.
10
+
11
+ `run` derives applicability from real paths, recent Git diffs, package boundaries, and the local import graph. Caller estimates cannot override this probe. Ambiguous work starts in the smaller mode and upgrades one level at a time while preserving existing evidence. Bypass makes no skill HTTP call, emits no automatic evaluation, and writes no requirements file.
12
+
13
+ Aimlock предназначен для существенных или явно назначенных инженерных изменений. Небольшая простая задача обходит цепочку только если пользователь явно не потребовал Aimlock, и выполняется напрямую либо одним профильным навыком.
14
+
15
+ `run` выводит факты из реальных путей, истории Git, границ пакетов и локального графа import; самооценка вызывающей стороны не переопределяет проверку. Неясная задача начинает с меньшего режима и повышается по одному уровню с сохранением доказательств. Bypass не выполняет HTTP-вызов, не создаёт автооценку и не записывает файл требований.
16
+
17
+ 激活后,单文件不超过 500 行走 Lock,最多三个文件且总计不超过 500 行走 Probe;目标文件超过三个、总改动超过 500 行、跨模块或必须并行时走 Swarm。Aimlock 不授予磁盘、网络、账号或系统“完全访问”,调用方只能按任务申请可撤销的最小权限。
18
+
19
+ When active, Aimlock uses Lock for one file through 500 changed lines and Probe for at most three files through 500 lines; more than three target files, more than 500 lines, cross-module work, or required parallelism uses Swarm. Aimlock never grants full access: callers must use revocable, task-scoped least privilege.
20
+
21
+ После активации Lock применяется к одному файлу до 500 строк, Probe — максимум к трём файлам до 500 строк; более трёх целевых файлов, более 500 строк, межмодульность или необходимая параллельность переводят работу в Swarm. Aimlock не выдаёт полный доступ: разрешения должны быть минимальными, отзывными и ограниченными задачей.
4
22
 
5
23
  - 把需求锁成可执行目标,阻止思考漂移、执行漂移、范围膨胀
6
- - Lock / Probe / Swarm 三档:小改不调蜂群;大改才派单
24
+ - Bypass / Lock / Probe / Swarm 分档:小改绕过;深度修改才进入门禁
7
25
  - 改前文件快照,禁止创建 git 分支
8
- - 按目标调用 Blueprint、Swarm、Calctool
26
+ - Lock / Probe / Swarm 分别强制 3 / 10 / 30 个文件与 2 / 8 / 15 分钟读取预算;Probe、Swarm 另有限制 30K / 100K 估算 token
27
+ - Ed25519 写入凭证绑定 chainId、快照摘要、路径集合和最长 300 秒有效期
28
+ - `probe.targetSymbols` 可从新鲜的 ContextBase 项目地图解析真实目标文件;缺失、歧义或陈旧条目直接阻断
29
+ - 服务端按当前需求实时发现专项技能,只返回命中项;非计算需求不出现 Calctool
9
30
 
10
31
  安装:`npx cli-aimlock@latest install`
11
32
 
33
+ ## 可信本地执行
34
+
35
+ IDE 宿主先调用本地探测与预算,再把批量写入统一路由到 `guarded-write`:
36
+
37
+ ```bash
38
+ cli-aimlock local capabilities .
39
+ cli-aimlock local probe .
40
+ cli-aimlock local budget-init .
41
+ cli-aimlock local budget-read .
42
+ cli-aimlock local gate-issue .
43
+ cli-aimlock local guarded-write .
44
+ ```
45
+
46
+ 每个命令从 stdin 读取 JSON。读取预算使用进程间原子锁,耗尽后只允许执行、输出方案或明确阻塞;追加预算必须携带 Confirm Protocol 的低风险确认回执。无凭证写入仅豁免 `.aimlock/logs/` 与 `.aimlock/tmp/`。
47
+
48
+ Swarm 模式下,`chain-plan` 会在 `swarm` 前插入 `coordinator.conflict-scan`。`gate-issue` 必须显式声明 `coordinationRequired`;为 true 时凭证绑定 `.coord/leases/` 中的签名文件锁,`guarded-write` 在同一拦截点同时校验门禁与活动租约。存在活动 `dependency-wait` 的 chain 会被 `budget-read` 拒绝。
49
+
50
+ 物理边界:本包能拒绝所有经过 `guarded-write` 的无证写入,但不能劫持任意 IDE 的系统调用。IDE 集成必须禁止其他批量写入口,并让门禁运行在独立低权限宿主中;否则不得声称实现了全局物理拦截。
51
+
12
52
 
13
53
  也可以直接从 CLI.Tax 对象存储安装(与站点「安装命令」一致):
14
54
 
@@ -18,4 +58,10 @@ npx https://cli.tax/cli-downloads/clitax-R3mQ8kWpXn.tgz install
18
58
 
19
59
  Source: https://github.com/88208555/aimlock-clitax.git
20
60
 
21
- 反馈:技能详情页「使用评价」支持 好评 / 差评 / 日常聊天。好评与差评计入市场口碑(跑马灯每日清理),日常消息保留 7 天。
61
+ ## 受限调用与自动评价
62
+
63
+ 使用 `npx cli-aimlock@latest invoke <operation> '<JSON对象>'`,或让 IDE 以 JSON stdin 调用 `npx cli-aimlock@latest broker`。broker 本身只需要 Brain Client HTTPS、受限身份文件和显式传入路径,不需要完整磁盘访问。要保证 IDE 看不到 token,必须把 broker 作为独立低权限账户或沙箱服务运行并只暴露受限 IPC;同一系统账户下的 `0600` 不能隔离 IDE 与 broker。
64
+
65
+ Brain Client 服务端在同一次 runtime 请求的事务中绑定真实响应、生成并持久化权威评分与评语,再返回已提交回执。broker 只验证 `feedbackReceiptId`、`feedbackInvocationId` 和权威摘要,不发起第二次评价写入,也不生成分数或评语。`not-reported`、验证不完整、P0/P1 findings、`blocked` 或 `failed` 都不得生成好评;缺凭证、缺回执、摘要不匹配、响应非法或 HTTP 失败都会显式失败。
66
+
67
+ 本地 CLI 不提供手工评分或评语提交命令,人类不能选择技能分数或填写技能评价。日常聊天不属于评价协议。
@@ -0,0 +1,42 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { repositoryRoot, resolvedProjectPath } from './aimlock-local-fs.mjs'
3
+
4
+ const CONTEXT_MAP_SCHEMA = 'contextbase.project-map/1.0'
5
+
6
+ function statSignature(status) {
7
+ return [status.dev, status.ino, status.size, status.mtimeMs, status.ctimeMs].join(':')
8
+ }
9
+
10
+ async function resolveContextMapTargets(root, targetSymbols) {
11
+ if (targetSymbols === undefined) return { targets: [], used: false }
12
+ if (!Array.isArray(targetSymbols) || targetSymbols.length === 0
13
+ || targetSymbols.some((symbol) => typeof symbol !== 'string' || !symbol.trim())) {
14
+ throw new Error('targetSymbols must be a non-empty string array')
15
+ }
16
+ const canonicalRoot = await repositoryRoot(root)
17
+ const mapFile = await resolvedProjectPath(canonicalRoot, '.contextbase/project-map.json')
18
+ const map = JSON.parse(await readFile(mapFile.target, 'utf8'))
19
+ if (map.schemaVersion !== CONTEXT_MAP_SCHEMA || !Array.isArray(map.entries)) {
20
+ throw new Error('ContextBase project map is invalid')
21
+ }
22
+ const targets = []
23
+ for (const requested of targetSymbols) {
24
+ const symbol = requested.trim()
25
+ const matches = map.entries.filter((entry) => Array.isArray(entry.exports)
26
+ && entry.exports.some((item) => item?.name === symbol))
27
+ if (matches.length !== 1) {
28
+ throw new Error(matches.length ? `${symbol} is ambiguous in the ContextBase map`
29
+ : `${symbol} is absent from the ContextBase map`)
30
+ }
31
+ const entry = matches[0]
32
+ const file = await resolvedProjectPath(canonicalRoot, entry.path)
33
+ if (statSignature(file.status) !== entry.signature) {
34
+ throw new Error(`ContextBase map entry is stale: ${entry.path}`)
35
+ }
36
+ targets.push(entry.path)
37
+ }
38
+ return { targets: [...new Set(targets)].sort(), used: true,
39
+ mapPath: mapFile.path }
40
+ }
41
+
42
+ export { CONTEXT_MAP_SCHEMA, resolveContextMapTargets }
@@ -0,0 +1,97 @@
1
+ import { verify } from 'node:crypto'
2
+ import { lstat, readFile } from 'node:fs/promises'
3
+ import { basename, resolve } from 'node:path'
4
+ import {
5
+ LEASE_SCHEMA,
6
+ leasePayload,
7
+ withCoordinationReadLock,
8
+ } from 'cli-swarm/coordinator-fs'
9
+ import {
10
+ fail,
11
+ identifier,
12
+ repositoryRoot,
13
+ resolvedProjectPath,
14
+ safeRelativePath,
15
+ sha256,
16
+ } from './aimlock-local-fs.mjs'
17
+
18
+ function targetCovered(target, lockedPath) {
19
+ return target === lockedPath || target.startsWith(`${lockedPath}/`)
20
+ }
21
+
22
+ async function regularFile(path, code, message) {
23
+ const status = await lstat(path)
24
+ if (!status.isFile() || status.isSymbolicLink()) fail(code, message)
25
+ return path
26
+ }
27
+
28
+ async function verifyLeaseAgainstState(state, root, input) {
29
+ const leasePath = safeRelativePath(input.coordinationLeasePath, 'coordinationLeasePath')
30
+ if (!leasePath.startsWith('.coord/leases/')) {
31
+ fail('AIMLOCK_COORDINATION_LEASE_PATH_INVALID', 'coordination lease must be inside .coord/leases')
32
+ }
33
+ const leaseFile = await resolvedProjectPath(root, leasePath)
34
+ await regularFile(leaseFile.target, 'AIMLOCK_COORDINATION_LEASE_INVALID', 'coordination lease must be a regular file')
35
+ const lease = JSON.parse(await readFile(leaseFile.target, 'utf8'))
36
+ const publicPath = resolve(root, '.coord', 'authority', 'public.pem')
37
+ await regularFile(publicPath, 'AIMLOCK_COORDINATION_AUTHORITY_INVALID', 'coordination public key must be a regular file')
38
+ const publicKey = await readFile(publicPath, 'utf8')
39
+ const chainId = identifier(input.chainId, 'chainId')
40
+ const targetPaths = input.targetPaths.map((path) => safeRelativePath(path, 'targetPath'))
41
+ const signatureValid = typeof lease.signature === 'string'
42
+ && verify(null, Buffer.from(JSON.stringify(leasePayload(lease))), publicKey,
43
+ Buffer.from(lease.signature, 'base64url'))
44
+ const lock = state.locks.find((item) => item.lockId === lease.lockId)
45
+ const issuedAt = Date.parse(lease.issuedAt)
46
+ const expiresAt = Date.parse(lease.expiresAt)
47
+ const valid = lease.schemaVersion === LEASE_SCHEMA
48
+ && basename(leasePath) === `${lease.leaseId}.json`
49
+ && lease.authorityKeyId === sha256(publicKey)
50
+ && lease.chainId === chainId
51
+ && lease.lockType === 'file'
52
+ && Array.isArray(lease.paths) && lease.paths.length > 0
53
+ && targetPaths.every((target) => lease.paths.some((path) => targetCovered(target, path)))
54
+ && Number.isFinite(issuedAt) && Number.isFinite(expiresAt)
55
+ && issuedAt <= Date.now() && expiresAt > Date.now()
56
+ && signatureValid
57
+ && lock?.status === 'active' && lock.leaseId === lease.leaseId
58
+ && lock.chainId === lease.chainId && lock.agentId === lease.agentId
59
+ && lock.expiresAt === lease.expiresAt
60
+ if (!valid) {
61
+ fail('AIMLOCK_COORDINATION_LEASE_INVALID', 'coordination lease is forged, expired, released, or outside its lock scope')
62
+ }
63
+ return { lease, lock, leaseDigest: sha256(JSON.stringify(lease)), targetPaths }
64
+ }
65
+
66
+ async function withCoordinationLease(input, action) {
67
+ if (!Array.isArray(input.targetPaths) || input.targetPaths.length === 0) {
68
+ fail('AIMLOCK_COORDINATION_TARGETS_REQUIRED', 'targetPaths must be a non-empty array')
69
+ }
70
+ return withCoordinationReadLock(input.repositoryRoot, async (state, root) => (
71
+ action(await verifyLeaseAgainstState(state, root, input))
72
+ ))
73
+ }
74
+
75
+ async function verifyCoordinationLease(input) {
76
+ return withCoordinationLease(input, async (verified) => verified)
77
+ }
78
+
79
+ async function assertChainNotSuspended(input) {
80
+ const root = await repositoryRoot(input.repositoryRoot)
81
+ const statePath = await resolvedProjectPath(root, '.coord/state.json', { allowMissing: true })
82
+ if (!statePath.exists) return { suspended: false }
83
+ const chainId = identifier(input.chainId, 'chainId')
84
+ return withCoordinationReadLock(root, async (state) => {
85
+ const wait = state.waits.find((item) => item.chainId === chainId && item.status === 'active')
86
+ if (wait) {
87
+ fail('AIMLOCK_COORDINATION_WAITING', `chain ${chainId} is suspended until ${wait.event} or ${wait.deadlineAt}`)
88
+ }
89
+ return { suspended: false }
90
+ })
91
+ }
92
+
93
+ export {
94
+ assertChainNotSuspended,
95
+ verifyCoordinationLease,
96
+ withCoordinationLease,
97
+ }
@@ -0,0 +1,172 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import {
3
+ appendFile,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ realpath,
9
+ rename,
10
+ unlink,
11
+ writeFile,
12
+ } from 'node:fs/promises'
13
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'
14
+ import { setTimeout as delay } from 'node:timers/promises'
15
+
16
+ const LOCAL_SCHEMA = 'aimlock.local-runner/1.0'
17
+ const MANAGED_ROOT = '.aimlock'
18
+ const LOCK_POLL_MS = 10
19
+ const LOCK_TIMEOUT_MS = 2_000
20
+
21
+ function sha256(value) {
22
+ return createHash('sha256').update(value).digest('hex')
23
+ }
24
+
25
+ function fail(code, message) {
26
+ const error = new Error(message)
27
+ error.code = code
28
+ throw error
29
+ }
30
+
31
+ function identifier(value, field) {
32
+ if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
33
+ fail('AIMLOCK_IDENTIFIER_INVALID', `${field} must be a safe identifier`)
34
+ }
35
+ return value
36
+ }
37
+
38
+ function safeRelativePath(value, field = 'path') {
39
+ if (typeof value !== 'string' || !value || value !== value.normalize('NFC')
40
+ || isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.includes('\\')
41
+ || /[\u0000-\u001f\u007f]/.test(value)) {
42
+ fail('AIMLOCK_PATH_UNSAFE', `${field} is unsafe`)
43
+ }
44
+ const parts = value.split('/')
45
+ if (parts.some((part) => !part || part === '.' || part === '..')) {
46
+ fail('AIMLOCK_PATH_UNSAFE', `${field} is unsafe`)
47
+ }
48
+ return parts.join('/')
49
+ }
50
+
51
+ function assertInside(root, target, field) {
52
+ const path = relative(root, target)
53
+ if (path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith('../'))) return
54
+ fail('AIMLOCK_PATH_ESCAPE', `${field} escapes the repository`)
55
+ }
56
+
57
+ async function canonicalMissingTarget(root, target) {
58
+ const suffix = [basename(target)]
59
+ let current = dirname(target)
60
+ for (;;) {
61
+ try {
62
+ const parent = await realpath(current)
63
+ assertInside(root, parent, 'missing path parent')
64
+ return resolve(parent, ...suffix)
65
+ } catch (error) {
66
+ if (!(error instanceof Error && error.code === 'ENOENT') || current === root) throw error
67
+ suffix.unshift(basename(current))
68
+ current = dirname(current)
69
+ }
70
+ }
71
+ }
72
+
73
+ async function repositoryRoot(value) {
74
+ const explicit = resolve(value)
75
+ const status = await lstat(explicit)
76
+ if (status.isSymbolicLink() || !status.isDirectory()) {
77
+ fail('AIMLOCK_ROOT_INVALID', 'repositoryRoot must be a real directory')
78
+ }
79
+ return realpath(explicit)
80
+ }
81
+
82
+ async function resolvedProjectPath(root, value, options = {}) {
83
+ const path = safeRelativePath(value)
84
+ const target = resolve(root, ...path.split('/'))
85
+ assertInside(root, target, 'path')
86
+ try {
87
+ const status = await lstat(target)
88
+ if (status.isSymbolicLink()) fail('AIMLOCK_PATH_SYMLINK', `${path} cannot be a symlink`)
89
+ const canonical = await realpath(target)
90
+ assertInside(root, canonical, 'path target')
91
+ return { path, target: canonical, status, exists: true }
92
+ } catch (error) {
93
+ if (!(error instanceof Error && error.code === 'ENOENT' && options.allowMissing)) throw error
94
+ return { path, target: await canonicalMissingTarget(root, target), status: null, exists: false }
95
+ }
96
+ }
97
+
98
+ function managedPath(root, ...parts) {
99
+ return resolve(root, MANAGED_ROOT, ...parts)
100
+ }
101
+
102
+ async function ensureManagedDirectory(root, ...parts) {
103
+ let current = root
104
+ for (const [index, part] of [MANAGED_ROOT, ...parts].entries()) {
105
+ if (index > 0) identifier(part, 'managed path segment')
106
+ current = resolve(current, part)
107
+ assertInside(root, current, 'managed directory')
108
+ try {
109
+ await mkdir(current, { mode: 0o700 })
110
+ } catch (error) {
111
+ if (!(error instanceof Error && error.code === 'EEXIST')) throw error
112
+ }
113
+ const status = await lstat(current)
114
+ if (status.isSymbolicLink() || !status.isDirectory()) {
115
+ fail('AIMLOCK_MANAGED_PATH_INVALID', 'managed directories must be real directories')
116
+ }
117
+ }
118
+ return current
119
+ }
120
+
121
+ async function appendAudit(root, record) {
122
+ const directory = await ensureManagedDirectory(root, 'audit')
123
+ await appendFile(resolve(directory, 'guard.jsonl'), `${JSON.stringify({
124
+ schemaVersion: LOCAL_SCHEMA,
125
+ at: new Date().toISOString(),
126
+ ...record,
127
+ })}\n`, { mode: 0o600 })
128
+ }
129
+
130
+ async function withFileLock(path, operation) {
131
+ const lockPath = `${path}.lock`
132
+ const deadline = Date.now() + LOCK_TIMEOUT_MS
133
+ let handle
134
+ while (!handle) {
135
+ try {
136
+ handle = await open(lockPath, 'wx', 0o600)
137
+ } catch (error) {
138
+ if (!(error instanceof Error && error.code === 'EEXIST')) throw error
139
+ if (Date.now() >= deadline) fail('AIMLOCK_LOCK_TIMEOUT', 'authority file is busy')
140
+ await delay(LOCK_POLL_MS)
141
+ }
142
+ }
143
+ try {
144
+ return await operation()
145
+ } finally {
146
+ await handle.close()
147
+ await unlink(lockPath)
148
+ }
149
+ }
150
+
151
+ async function atomicJson(path, value) {
152
+ const temporary = `${path}.${randomUUID()}.tmp`
153
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { flag: 'wx', mode: 0o600 })
154
+ await rename(temporary, path)
155
+ }
156
+
157
+ export {
158
+ LOCAL_SCHEMA,
159
+ MANAGED_ROOT,
160
+ appendAudit,
161
+ assertInside,
162
+ atomicJson,
163
+ ensureManagedDirectory,
164
+ fail,
165
+ identifier,
166
+ managedPath,
167
+ repositoryRoot,
168
+ resolvedProjectPath,
169
+ safeRelativePath,
170
+ sha256,
171
+ withFileLock,
172
+ }
@@ -0,0 +1,275 @@
1
+ import { generateKeyPairSync, randomUUID, sign, verify } from 'node:crypto'
2
+ import { lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
3
+ import { basename, dirname, resolve } from 'node:path'
4
+ import { mutateGate, snapshotVerify } from './aimlock-runtime.mjs'
5
+ import { verifyCoordinationLease, withCoordinationLease } from './aimlock-coordination.mjs'
6
+ import {
7
+ LOCAL_SCHEMA,
8
+ MANAGED_ROOT,
9
+ appendAudit,
10
+ ensureManagedDirectory,
11
+ fail,
12
+ identifier,
13
+ repositoryRoot,
14
+ resolvedProjectPath,
15
+ safeRelativePath,
16
+ sha256,
17
+ } from './aimlock-local-fs.mjs'
18
+
19
+ const PASS_SCHEMA = 'aimlock.mutate-pass/1.0'
20
+ const MAX_GATE_TTL_SECONDS = 300
21
+ const MISSING_SHA256 = sha256('aimlock.missing-file/1.0')
22
+
23
+ function passPayload(pass) {
24
+ return {
25
+ schemaVersion: pass.schemaVersion,
26
+ passId: pass.passId,
27
+ chainId: pass.chainId,
28
+ authorityKeyId: pass.authorityKeyId,
29
+ issuedAt: pass.issuedAt,
30
+ expiresAt: pass.expiresAt,
31
+ contractDigest: pass.contractDigest,
32
+ snapshotReceiptDigest: pass.snapshotReceiptDigest,
33
+ pathDigest: pass.pathDigest,
34
+ paths: pass.paths,
35
+ coordinationRequired: pass.coordinationRequired,
36
+ coordinationLeasePath: pass.coordinationLeasePath,
37
+ coordinationLeaseId: pass.coordinationLeaseId,
38
+ coordinationLockId: pass.coordinationLockId,
39
+ coordinationLeaseDigest: pass.coordinationLeaseDigest,
40
+ nonce: pass.nonce,
41
+ }
42
+ }
43
+
44
+ async function pathExists(path) {
45
+ try {
46
+ await lstat(path)
47
+ return true
48
+ } catch (error) {
49
+ if (error instanceof Error && error.code === 'ENOENT') return false
50
+ throw error
51
+ }
52
+ }
53
+
54
+ async function authorityKeys(root) {
55
+ const directory = await ensureManagedDirectory(root, 'authority')
56
+ const privatePath = resolve(directory, 'private.pem')
57
+ const publicPath = resolve(directory, 'public.pem')
58
+ const privateExists = await pathExists(privatePath)
59
+ const publicExists = await pathExists(publicPath)
60
+ if (privateExists !== publicExists) fail('AIMLOCK_AUTHORITY_INVALID', 'authority key pair is incomplete')
61
+ if (privateExists) {
62
+ const [privateStatus, publicStatus] = await Promise.all([lstat(privatePath), lstat(publicPath)])
63
+ if (privateStatus.isSymbolicLink() || publicStatus.isSymbolicLink()
64
+ || !privateStatus.isFile() || !publicStatus.isFile()) {
65
+ fail('AIMLOCK_AUTHORITY_INVALID', 'authority keys must be regular files')
66
+ }
67
+ }
68
+ if (!privateExists) {
69
+ const pair = generateKeyPairSync('ed25519', {
70
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
71
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
72
+ })
73
+ await writeFile(privatePath, pair.privateKey, { flag: 'wx', mode: 0o600 })
74
+ await writeFile(publicPath, pair.publicKey, { flag: 'wx', mode: 0o600 })
75
+ }
76
+ const privateKey = await readFile(privatePath, 'utf8')
77
+ const publicKey = await readFile(publicPath, 'utf8')
78
+ return { privateKey, publicKey, keyId: sha256(publicKey) }
79
+ }
80
+
81
+ async function fileHash(path) {
82
+ return sha256(await readFile(path))
83
+ }
84
+
85
+ async function realSnapshotReceipt(root, input) {
86
+ if (!input.receipt || !Array.isArray(input.receipt.paths) || !Array.isArray(input.nodes)) {
87
+ fail('AIMLOCK_SNAPSHOT_AUTHORITY_INVALID', 'receipt paths and nodes are required')
88
+ }
89
+ const snapshotRoot = safeRelativePath(input.snapshotRoot, 'snapshotRoot')
90
+ if (!snapshotRoot.startsWith(`${MANAGED_ROOT}/snapshots/`)) {
91
+ fail('AIMLOCK_SNAPSHOT_ROOT_INVALID', 'snapshotRoot must be inside .aimlock/snapshots')
92
+ }
93
+ const nodes = new Map(input.nodes.map((node) => [node.path, node]))
94
+ const files = []
95
+ for (const path of input.receipt.paths) {
96
+ const source = await resolvedProjectPath(root, path, { allowMissing: true })
97
+ const newNode = nodes.get(path)?.isNewFile === true
98
+ const snapshotPath = `${snapshotRoot}/${path}`
99
+ if (!source.exists && newNode && !(await pathExists(resolve(root, snapshotPath)))) {
100
+ files.push({ path, sourceHash: MISSING_SHA256, snapshotHash: MISSING_SHA256 })
101
+ continue
102
+ }
103
+ const snapshot = await resolvedProjectPath(root, snapshotPath, { allowMissing: true })
104
+ if (!source.exists || !snapshot.exists || !source.status.isFile() || !snapshot.status.isFile()) {
105
+ fail('AIMLOCK_SNAPSHOT_AUTHORITY_INVALID', `${path} does not match its snapshot existence`)
106
+ }
107
+ files.push({ path, sourceHash: await fileHash(source.target),
108
+ snapshotHash: await fileHash(snapshot.target) })
109
+ }
110
+ const verified = snapshotVerify({ snapshotId: input.receipt.snapshotId,
111
+ contract: input.contract, nodes: input.nodes, files })
112
+ if (verified.findings || verified.receipt?.receiptDigest !== input.receipt.receiptDigest) {
113
+ fail('AIMLOCK_SNAPSHOT_AUTHORITY_INVALID', 'snapshot files do not match the supplied receipt')
114
+ }
115
+ return verified.receipt
116
+ }
117
+
118
+ async function issueMutationPass(input) {
119
+ const root = await repositoryRoot(input.repositoryRoot)
120
+ const chainId = identifier(input.chainId, 'chainId')
121
+ const receipt = await realSnapshotReceipt(root, input)
122
+ const gate = mutateGate({ accepted: true, receipt: input.receipt,
123
+ contract: input.contract, nodes: input.nodes })
124
+ if (gate.findings || gate.allowed !== true) fail('AIMLOCK_MUTATE_GATE_BLOCKED', 'mutate-gate did not allow this scope')
125
+ const ttlSeconds = input.ttlSeconds === undefined ? MAX_GATE_TTL_SECONDS : input.ttlSeconds
126
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > MAX_GATE_TTL_SECONDS) {
127
+ fail('AIMLOCK_GATE_TTL_INVALID', `ttlSeconds must be 1..${MAX_GATE_TTL_SECONDS}`)
128
+ }
129
+ if (typeof input.coordinationRequired !== 'boolean') {
130
+ fail('AIMLOCK_COORDINATION_POLICY_REQUIRED', 'coordinationRequired must be an explicit boolean')
131
+ }
132
+ if (!input.coordinationRequired && input.coordinationLeasePath !== undefined) {
133
+ fail('AIMLOCK_COORDINATION_POLICY_INVALID', 'a non-coordinated pass cannot carry a coordination lease')
134
+ }
135
+ const coordination = input.coordinationRequired
136
+ ? await verifyCoordinationLease({ repositoryRoot: root, chainId,
137
+ coordinationLeasePath: input.coordinationLeasePath, targetPaths: receipt.paths })
138
+ : null
139
+ const authority = await authorityKeys(root)
140
+ const issuedAt = new Date()
141
+ const pass = {
142
+ schemaVersion: PASS_SCHEMA,
143
+ passId: `pass-${randomUUID()}`,
144
+ chainId,
145
+ authorityKeyId: authority.keyId,
146
+ issuedAt: issuedAt.toISOString(),
147
+ expiresAt: new Date(issuedAt.getTime() + ttlSeconds * 1_000).toISOString(),
148
+ contractDigest: receipt.contractDigest,
149
+ snapshotReceiptDigest: receipt.receiptDigest,
150
+ pathDigest: receipt.pathDigest,
151
+ paths: [...receipt.paths],
152
+ coordinationRequired: input.coordinationRequired,
153
+ coordinationLeasePath: coordination ? input.coordinationLeasePath : null,
154
+ coordinationLeaseId: coordination?.lease.leaseId ?? null,
155
+ coordinationLockId: coordination?.lock.lockId ?? null,
156
+ coordinationLeaseDigest: coordination?.leaseDigest ?? null,
157
+ nonce: randomUUID(),
158
+ }
159
+ const signature = sign(null, Buffer.from(JSON.stringify(passPayload(pass))), authority.privateKey)
160
+ .toString('base64url')
161
+ const signedPass = { ...pass, signature }
162
+ const directory = await ensureManagedDirectory(root, 'gates')
163
+ const passPath = resolve(directory, `${pass.passId}.json`)
164
+ await writeFile(passPath, `${JSON.stringify(signedPass)}\n`, { flag: 'wx', mode: 0o600 })
165
+ await appendAudit(root, { event: 'mutate-pass-issued', chainId, passId: pass.passId,
166
+ expiresAt: pass.expiresAt, pathDigest: pass.pathDigest })
167
+ return { schemaVersion: LOCAL_SCHEMA, gatePassPath: `${MANAGED_ROOT}/gates/${pass.passId}.json`,
168
+ passPath, pass: signedPass }
169
+ }
170
+
171
+ async function verifyMutationPassFile(input) {
172
+ const root = await repositoryRoot(input.repositoryRoot)
173
+ const gatePath = safeRelativePath(input.gatePassPath, 'gatePassPath')
174
+ if (!gatePath.startsWith(`${MANAGED_ROOT}/gates/`)) {
175
+ fail('AIMLOCK_GATE_PATH_INVALID', 'gate pass must be inside .aimlock/gates')
176
+ }
177
+ await ensureManagedDirectory(root, 'gates')
178
+ const gateFile = await resolvedProjectPath(root, gatePath)
179
+ const pass = JSON.parse(await readFile(gateFile.target, 'utf8'))
180
+ const chainId = identifier(input.chainId, 'chainId')
181
+ const targetPath = safeRelativePath(input.targetPath, 'targetPath')
182
+ const authorityDirectory = await ensureManagedDirectory(root, 'authority')
183
+ const publicPath = resolve(authorityDirectory, 'public.pem')
184
+ const publicStatus = await lstat(publicPath)
185
+ if (publicStatus.isSymbolicLink() || !publicStatus.isFile()) {
186
+ fail('AIMLOCK_AUTHORITY_INVALID', 'authority public key must be a regular file')
187
+ }
188
+ const publicKey = await readFile(publicPath, 'utf8')
189
+ const issuedAt = Date.parse(pass.issuedAt)
190
+ const expiresAt = Date.parse(pass.expiresAt)
191
+ const signatureValid = typeof pass.signature === 'string'
192
+ && verify(null, Buffer.from(JSON.stringify(passPayload(pass))), publicKey,
193
+ Buffer.from(pass.signature, 'base64url'))
194
+ if (pass.schemaVersion !== PASS_SCHEMA || pass.chainId !== chainId
195
+ || pass.authorityKeyId !== sha256(publicKey) || basename(gatePath) !== `${pass.passId}.json`
196
+ || !Array.isArray(pass.paths) || !pass.paths.includes(targetPath) || !signatureValid
197
+ || pass.pathDigest !== sha256(JSON.stringify(pass.paths))
198
+ || !Number.isFinite(issuedAt) || !Number.isFinite(expiresAt)
199
+ || issuedAt > Date.now() || expiresAt <= Date.now()) {
200
+ fail('AIMLOCK_GATE_PASS_INVALID', 'gate pass is forged, expired, or outside its bound scope')
201
+ }
202
+ let coordination = null
203
+ if (pass.coordinationRequired === true) {
204
+ coordination = await verifyCoordinationLease({ repositoryRoot: root, chainId,
205
+ coordinationLeasePath: pass.coordinationLeasePath, targetPaths: [targetPath] })
206
+ if (coordination.lease.leaseId !== pass.coordinationLeaseId
207
+ || coordination.lock.lockId !== pass.coordinationLockId
208
+ || coordination.leaseDigest !== pass.coordinationLeaseDigest) {
209
+ fail('AIMLOCK_COORDINATION_LEASE_INVALID', 'coordination lease does not match the gate pass')
210
+ }
211
+ } else if (pass.coordinationRequired !== false || pass.coordinationLeasePath !== null
212
+ || pass.coordinationLeaseId !== null || pass.coordinationLockId !== null
213
+ || pass.coordinationLeaseDigest !== null) {
214
+ fail('AIMLOCK_GATE_PASS_INVALID', 'gate pass has an invalid coordination policy')
215
+ }
216
+ return { schemaVersion: LOCAL_SCHEMA, verified: true, pass, targetPath, coordination }
217
+ }
218
+
219
+ function whitelistedPath(path) {
220
+ return path.startsWith(`${MANAGED_ROOT}/logs/`) || path.startsWith(`${MANAGED_ROOT}/tmp/`)
221
+ }
222
+
223
+ async function guardedWriteFile(input) {
224
+ const root = await repositoryRoot(input.repositoryRoot)
225
+ const targetPath = safeRelativePath(input.targetPath, 'targetPath')
226
+ let passId = null
227
+ try {
228
+ if (whitelistedPath(targetPath)) {
229
+ const bucket = targetPath.split('/')[1]
230
+ await ensureManagedDirectory(root, bucket)
231
+ }
232
+ let verified = null
233
+ if (!whitelistedPath(targetPath)) {
234
+ if (!input.gatePassPath) fail('AIMLOCK_GATE_PASS_REQUIRED', 'a gate pass is required')
235
+ verified = await verifyMutationPassFile({ ...input, targetPath })
236
+ passId = verified.pass.passId
237
+ }
238
+ const performWrite = async () => {
239
+ const target = await resolvedProjectPath(root, targetPath, { allowMissing: true })
240
+ await mkdir(dirname(target.target), { recursive: true })
241
+ const temporary = resolve(dirname(target.target), `.${basename(target.target)}.${randomUUID()}.tmp`)
242
+ await writeFile(temporary, input.content, {
243
+ flag: 'wx', mode: target.exists ? target.status.mode & 0o777 : 0o600,
244
+ })
245
+ await rename(temporary, target.target)
246
+ }
247
+ if (verified?.pass.coordinationRequired === true) {
248
+ await withCoordinationLease({ repositoryRoot: root, chainId: input.chainId,
249
+ coordinationLeasePath: verified.pass.coordinationLeasePath, targetPaths: [targetPath] },
250
+ async (coordination) => {
251
+ if (coordination.leaseDigest !== verified.pass.coordinationLeaseDigest) {
252
+ fail('AIMLOCK_COORDINATION_LEASE_INVALID', 'coordination lease changed after pass verification')
253
+ }
254
+ await performWrite()
255
+ })
256
+ } else {
257
+ await performWrite()
258
+ }
259
+ await appendAudit(root, { event: 'guarded-write-allowed', chainId: input.chainId ?? null,
260
+ targetPath, passId, whitelisted: whitelistedPath(targetPath) })
261
+ return { schemaVersion: LOCAL_SCHEMA, written: true, targetPath, passId }
262
+ } catch (error) {
263
+ await appendAudit(root, { event: 'guarded-write-denied', chainId: input.chainId ?? null,
264
+ targetPath, passId, reason: error instanceof Error ? error.message : String(error) })
265
+ throw error
266
+ }
267
+ }
268
+
269
+ export {
270
+ PASS_SCHEMA,
271
+ guardedWriteFile,
272
+ issueMutationPass,
273
+ passPayload,
274
+ verifyMutationPassFile,
275
+ }