@starci/hfs 4.8.0 → 4.9.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 +10 -0
- package/emit/operation-schemas.mjs +3 -0
- package/lint/bound-sys.cjs +2 -2
- package/package.json +1 -1
- package/runtime/engine/admission.mjs +1 -1
- package/runtime/engine/config.mjs +2 -2
- package/runtime/engine/db/machine.mjs +2 -3
- package/runtime/engine/invalid-config.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -14
- package/runtime/knowledge/hfs/rules.yaml +23 -0
- package/runtime/modules/kernel/failure-codes.yaml +10 -0
- package/runtime/scripts/guards/git-policy.mjs +2 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
- package/runtime/scripts/lib/package-at.mjs +5 -6
- package/runtime/scripts/lib/roots.mjs +2 -2
- package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
- package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
- package/scaffold/app.mjs +4 -4
- package/src/main.mjs +3 -3
- package/sync/index.mjs +14 -1
- package/sync/managed.mjs +3 -2
- package/templates/app/codegen-graphql.mjs.partial +4 -4
- package/templates/app/quality-config/sonar-project.properties +4 -2
- package/runtime/engine/canonical-json.mjs +0 -14
- package/runtime/engine/db/ledger-open.mjs +0 -38
- package/runtime/engine/db/ledger-paths.mjs +0 -87
- package/runtime/engine/db/ledger.mjs +0 -1068
- package/runtime/engine/db/provision-authority.mjs +0 -76
- package/runtime/engine/db/provision-operations.mjs +0 -146
- package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
- package/runtime/scripts/agent/codex-app-server.mjs +0 -79
- package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
- package/runtime/scripts/agent/host-agents.mjs +0 -80
- package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
- package/runtime/scripts/agent/trust-launch.mjs +0 -191
- package/runtime/scripts/agent/trust.mjs +0 -523
- package/runtime/scripts/api/fs/temp-path.mjs +0 -8
- package/runtime/scripts/api/git/diff-names.mjs +0 -8
- package/runtime/scripts/api/git/head-time.mjs +0 -10
- package/runtime/scripts/api/git/index-lock.mjs +0 -78
- package/runtime/scripts/api/git/log.mjs +0 -5
- package/runtime/scripts/api/git/merge-tree.mjs +0 -5
- package/runtime/scripts/api/git/rev-list.mjs +0 -5
- package/runtime/scripts/api/git/rev-parse.mjs +0 -13
- package/runtime/scripts/api/git/status-query.mjs +0 -5
- package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
- package/runtime/scripts/api/npm/lib.mjs +0 -32
- package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
- package/runtime/scripts/api/npm/run-npm.mjs +0 -6
- package/runtime/scripts/api/npm/view.mjs +0 -30
- package/runtime/scripts/api/npm/whoami.mjs +0 -8
- package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
- package/runtime/scripts/api/process/kill-tree.mjs +0 -22
- package/runtime/scripts/api/process/lib.mjs +0 -125
- package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
- package/runtime/scripts/api/process/process-list.mjs +0 -28
- package/runtime/scripts/api/process/system-tool.mjs +0 -37
- package/runtime/scripts/gates/canon-digest.mjs +0 -81
- package/runtime/scripts/gates/canon-pins.mjs +0 -119
- package/runtime/scripts/gates/gate-input.mjs +0 -98
- package/runtime/scripts/gates/gate.mjs +0 -636
- package/runtime/scripts/gates/grammar-dist.mjs +0 -206
- package/runtime/scripts/gates/output.mjs +0 -40
- package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
- package/runtime/scripts/gates/test-world-run.mjs +0 -252
- package/runtime/scripts/gates/type-impact.mjs +0 -277
- package/runtime/scripts/gates/type-program.mjs +0 -113
- package/runtime/scripts/guards/assigned-command.mjs +0 -12
- package/runtime/scripts/guards/call-rights.mjs +0 -47
- package/runtime/scripts/guards/command-guard.mjs +0 -468
- package/runtime/scripts/guards/command-policy.mjs +0 -342
- package/runtime/scripts/guards/critic-reach.mjs +0 -82
- package/runtime/scripts/guards/deps-guard.mjs +0 -150
- package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
- package/runtime/scripts/guards/guards-root.mjs +0 -27
- package/runtime/scripts/guards/hook-install.mjs +0 -343
- package/runtime/scripts/guards/hook-verdict.mjs +0 -12
- package/runtime/scripts/guards/install-verdict.mjs +0 -41
- package/runtime/scripts/guards/kernel-seat.mjs +0 -63
- package/runtime/scripts/guards/launch-verdict.mjs +0 -74
- package/runtime/scripts/guards/op-starci.mjs +0 -16
- package/runtime/scripts/guards/op-write-scope.mjs +0 -27
- package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
- package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
- package/runtime/scripts/guards/protected-zone.mjs +0 -56
- package/runtime/scripts/guards/refusals.mjs +0 -27
- package/runtime/scripts/guards/rights.mjs +0 -268
- package/runtime/scripts/guards/seat-tools.mjs +0 -47
- package/runtime/scripts/guards/shell-commands.mjs +0 -328
- package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
- package/runtime/scripts/guards/verdict-process.mjs +0 -81
- package/runtime/scripts/lib/app-data.mjs +0 -11
- package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
- package/runtime/scripts/lib/check-format.mjs +0 -7
- package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
- package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
- package/runtime/scripts/lib/git-dir.mjs +0 -120
- package/runtime/scripts/lib/guard-command.mjs +0 -24
- package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
- package/runtime/scripts/lib/json-formats.mjs +0 -34
- package/runtime/scripts/lib/managed-json.mjs +0 -51
- package/runtime/scripts/lib/menu-parts.mjs +0 -11
- package/runtime/scripts/lib/op-gate.mjs +0 -15
- package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
- package/runtime/scripts/lib/release-version.mjs +0 -134
- package/runtime/scripts/lib/runtime-location.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
- package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
- package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
- package/runtime/scripts/lib/seat-env.mjs +0 -43
- package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
- package/runtime/scripts/lib/source-package-imports.mjs +0 -111
- package/runtime/scripts/lib/tar-files.mjs +0 -39
- package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
- package/runtime/scripts/lib/worktree-record.mjs +0 -14
- package/runtime/scripts/machine/ask-receipts.mjs +0 -113
- package/runtime/scripts/machine/evidence-store.mjs +0 -223
- package/runtime/scripts/machine/home.mjs +0 -216
- package/runtime/scripts/machine/host-lock.mjs +0 -184
- package/runtime/scripts/machine/lock-recovery.mjs +0 -55
- package/runtime/scripts/machine/roles-contract.mjs +0 -90
- package/runtime/scripts/machine/runtime-change.mjs +0 -20
- package/runtime/scripts/machine/workflow-tree.mjs +0 -122
- package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
- package/runtime/scripts/reconciler/guard-row.mjs +0 -25
- package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
- package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
- package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
- package/runtime/scripts/work/brand/brand.mjs +0 -892
- package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
- package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
- package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
- package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
- package/runtime/scripts/work/draw/draw-source.mjs +0 -412
- package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
- package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
- package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
- package/runtime/scripts/work/draw-grammar.mjs +0 -175
- package/runtime/scripts/work/draw-render-page.mjs +0 -188
- package/runtime/scripts/work/draw-render.mjs +0 -626
- package/runtime/scripts/work/png.mjs +0 -237
- package/runtime/scripts/work/render-tools.mjs +0 -64
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
// command-policy.mjs - the shared R223 command-policy evaluator.
|
|
2
|
-
//
|
|
3
|
-
// Both enforcement entries call policyVerdict with the object loaded from modules/kernel/command-policy.yaml:
|
|
4
|
-
// command-guard.mjs for PreToolUse hosts, and shimDecision for the PATH wrappers installed in every Orca worker terminal.
|
|
5
|
-
// A wrapper calls `await shimDecision({ program: 'git', args: process.argv.slice(2) })`; on a verdict it writes
|
|
6
|
-
// shimRefusalLines(verdict) to stderr and exits 2, otherwise it execs the real tool. A missing or unreadable policy fails
|
|
7
|
-
// open, because the guard never takes the shell away on its own fault.
|
|
8
|
-
import fs from 'node:fs';
|
|
9
|
-
import path from 'node:path';
|
|
10
|
-
import { parseYaml } from '../../engine/yaml.mjs';
|
|
11
|
-
import { slash } from '../lib/path-key.mjs';
|
|
12
|
-
import { SKILL_ROOT } from './guards-root.mjs';
|
|
13
|
-
import { boundGuard, boundSeat, gitListFormRead, gitSubOf, nodeWholeSuite, pushTargets, refusal as baseRefusal, rightsRoleOf } from './rights.mjs';
|
|
14
|
-
import { refusalLines } from './refusals.mjs';
|
|
15
|
-
import { supervisorSeatVerdict } from './supervisor-seat.mjs';
|
|
16
|
-
import { opStarciVerdict } from './op-starci.mjs';
|
|
17
|
-
import { kernelMailboxVerdict } from './install-verdict.mjs';
|
|
18
|
-
import { orcaAddressedOption, orcaSelfLifecycleAllowed } from './orca-self-lifecycle.mjs';
|
|
19
|
-
import { criticCommandVerdict } from './critic-reach.mjs';
|
|
20
|
-
import { rolesContract } from '../machine/roles-contract.mjs';
|
|
21
|
-
import { kernelSeatVerdict } from './kernel-seat.mjs';
|
|
22
|
-
|
|
23
|
-
const policyCache = new Map();
|
|
24
|
-
const compiledCache = new WeakMap();
|
|
25
|
-
const toSet = (value) => new Set(Array.isArray(value) ? value.map(String) : []);
|
|
26
|
-
const programName = (value) => path.basename(slash(value)).toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/, '');
|
|
27
|
-
// These utilities have no state-changing form. Keeping this tiny compiled subset beside the evaluator lets the hook avoid
|
|
28
|
-
// filesystem/YAML work for its hottest path; the complete admission table remains command-policy.yaml and is tested through
|
|
29
|
-
// policyVerdict. Programs with even one writing option belong to guarded-read and are deliberately absent here.
|
|
30
|
-
const INTRINSIC_READ = new Set(['ls', 'dir', 'cat', 'type', 'head', 'tail', 'wc', 'uniq', 'cut', 'tr', 'diff', 'cmp', 'comm', 'rg', 'grep', 'egrep', 'fgrep',
|
|
31
|
-
'pwd', 'which', 'where', 'whoami', 'hostname', 'stat', 'file', 'realpath', 'readlink', 'dirname', 'basename', 'date', 'true', 'false', 'test', '[', 'expr',
|
|
32
|
-
'seq', 'md5sum', 'sha1sum', 'sha256sum', 'shasum', 'cksum', 'od', 'xxd', 'hexdump', 'jq', 'yq', 'column', 'nl', 'rev', 'tac', 'fold', 'fmt', 'join', 'paste']);
|
|
33
|
-
|
|
34
|
-
/** True only for a program whose every form is intrinsically read-only. */
|
|
35
|
-
// A PowerShell drive path like env: lists the whole environment (ls env:, dir env:, cat env:): never the hot path, the ENV_DUMP rule judges it.
|
|
36
|
-
export const intrinsicPolicyRead = (command) => INTRINSIC_READ.has(programName(command?.program)) && !(command?.args ?? []).some((a) => /env:/i.test(String(a)));
|
|
37
|
-
|
|
38
|
-
/** Load and parse the command policy once per runtime root. An unreadable or invalid file is cached as policy null. */
|
|
39
|
-
export function loadCommandPolicy({ root = SKILL_ROOT, read = (file) => fs.readFileSync(file, 'utf8') } = {}) {
|
|
40
|
-
const key = path.resolve(root);
|
|
41
|
-
if (policyCache.has(key)) return policyCache.get(key);
|
|
42
|
-
let policy = null;
|
|
43
|
-
try {
|
|
44
|
-
const value = parseYaml(String(read(path.join(key, 'modules', 'kernel', 'command-policy.yaml'))));
|
|
45
|
-
if (value && typeof value === 'object' && !Array.isArray(value)) policy = value;
|
|
46
|
-
} catch { /* fail open */ }
|
|
47
|
-
policyCache.set(key, policy);
|
|
48
|
-
return policy;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const compile = (policy) => {
|
|
52
|
-
if (!policy || typeof policy !== 'object') return null;
|
|
53
|
-
if (compiledCache.has(policy)) return compiledCache.get(policy);
|
|
54
|
-
let backupRef = null;
|
|
55
|
-
try { backupRef = new RegExp(String(policy.roles?.['backup-ref'] ?? '')); } catch { /* invalid policy fails closed nowhere */ }
|
|
56
|
-
const value = {
|
|
57
|
-
bound: toSet(policy.roles?.bound), nodeTest: toSet(policy.roles?.['node-test']), suiteOps: toSet(policy.roles?.['suite-ops']),
|
|
58
|
-
backupRoles: toSet(policy.roles?.['backup-roles']), backupRef,
|
|
59
|
-
runtime: toSet(policy.runtime), nodeScripts: Array.isArray(policy['node-scripts']) ? policy['node-scripts'].map((file) => slash(file)) : [],
|
|
60
|
-
read: toSet(policy.read), scopedWrite: toSet(policy['scoped-write']), npmPrograms: toSet(policy.npm?.programs), npmRead: toSet(policy.npm?.read),
|
|
61
|
-
gitRead: toSet(policy.git?.read), gitListForms: toSet(policy.git?.['list-forms']), orcaRead: toSet(policy.orca?.read), orcaGroups: toSet(policy.orca?.['groups-read']),
|
|
62
|
-
};
|
|
63
|
-
compiledCache.set(policy, value);
|
|
64
|
-
return value;
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
const refusal = (code, command, reason, use) => baseRefusal(code, command, reason, use, use);
|
|
68
|
-
const textOf = ({ program, args, word }) => [word ?? program, ...args].map(String).join(' ').trim();
|
|
69
|
-
const useOf = (entry, fallback) => typeof entry?.use === 'string' && entry.use ? entry.use : fallback;
|
|
70
|
-
const RIGHTS_CODES = new Set(['RIGHTS_ROLE_DENIED', 'RIGHTS_GIT_PUSH', 'RIGHTS_GIT_TAG', 'RIGHTS_GIT_COMMIT', 'RIGHTS_GIT_SYNC', 'RIGHTS_NPM_PUBLISH', 'RIGHTS_NPM_CI_UNLOCKED',
|
|
71
|
-
'RIGHTS_SUITE_RUN', 'RIGHTS_RELEASE_CUT', 'RIGHTS_RAW_TOOL']);
|
|
72
|
-
/** The code a table entry names, when it is one of the R223 codes (a typo in the table never invents a code). */
|
|
73
|
-
const codeOf = (entry, fallback) => (RIGHTS_CODES.has(entry?.code) ? entry.code : fallback);
|
|
74
|
-
const genericUse = 'use the starci verb of the action (modules/cli/commands) or ask the owner';
|
|
75
|
-
const raw = (role, text, use = genericUse, what = 'raw tool command') => refusal('RIGHTS_RAW_TOOL', text, `the ${role} role does not run this ${what} directly because side effects go through a starci verb`, use);
|
|
76
|
-
|
|
77
|
-
/** A headless-call verb (policy.calls) or a capability verb (policy.renders) refuses every bound role but the ops the table names. */
|
|
78
|
-
const callVerdict = ({ role, program, args, guard, policy, text }) => {
|
|
79
|
-
if (program !== 'starci') return null;
|
|
80
|
-
const words = args.filter((value) => !value.startsWith('-'));
|
|
81
|
-
const call = [...Object.values(policy.calls ?? {}), ...Object.values(policy.renders ?? {})].find((entry) => words[0] === entry.verb?.[0] && words[1] === entry.verb?.[1]);
|
|
82
|
-
if (!call || (role === 'op' && toSet(call.ops).has(String(guard?.op ?? '')))) return null;
|
|
83
|
-
return refusal('RIGHTS_ROLE_DENIED', text, `${guard?.op ?? role} does not run starci ${call.verb.join(' ')}: only ${[...toSet(call.ops)].join(', ')} call it`, useOf(call, genericUse));
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
const RELEASE_SCRIPTS = new Set(['release:cut', 'release:publish']);
|
|
87
|
-
const releaseCall = (program, args, policy) => {
|
|
88
|
-
if (program === 'starci' && args[0] === 'release' && toSet(policy.release?.verbs).has(String(args[1] ?? ''))) return true;
|
|
89
|
-
if (program === 'node' && args.some((value) => /(?:^|[\\/])release-(?:cut|publish)\.mjs$/i.test(String(value)))) return true;
|
|
90
|
-
return toSet(policy.npm?.programs).has(program) && args.some((value, index) => RELEASE_SCRIPTS.has(String(value)) && ['run', 'run-script'].includes(String(args[index - 1] ?? '')));
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const PM_VALUE_FLAGS = new Set(['--prefix', '--cwd', '-C', '--dir', '--workspace', '-w', '--registry', '--tag', '--filter']);
|
|
94
|
-
const packageWords = (args) => {
|
|
95
|
-
const words = [];
|
|
96
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
97
|
-
const value = String(args[i]);
|
|
98
|
-
if (value === '--') break;
|
|
99
|
-
if (value.startsWith('-')) {
|
|
100
|
-
const name = value.split('=', 1)[0];
|
|
101
|
-
if (!value.includes('=') && PM_VALUE_FLAGS.has(name)) i += 1;
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
words.push(value);
|
|
105
|
-
}
|
|
106
|
-
return words;
|
|
107
|
-
};
|
|
108
|
-
const afterDashDash = (args) => { const at = args.indexOf('--'); return at < 0 ? [] : args.slice(at + 1).map(String).filter((value) => !value.startsWith('-')); };
|
|
109
|
-
const cleanInstall = (word) => ['ci', 'clean-install', 'install-clean', 'cit', 'install-ci-test'].includes(word);
|
|
110
|
-
const suiteScript = (words) => ['test', 't'].includes(words[0]) || (['run', 'run-script'].includes(words[0]) && words[1] === 'test');
|
|
111
|
-
const publishScript = (words) => ['run', 'run-script'].includes(words[0]) && /^(?:release:)?publish.*$/.test(words[1] ?? '');
|
|
112
|
-
|
|
113
|
-
const nodeValueFlags = new Set(['--import', '--require', '-r', '--loader', '--experimental-loader', '--conditions', '-C', '--test-reporter', '--test-reporter-destination', '--test-concurrency', '--test-name-pattern', '--test-skip-pattern', '--test-timeout']);
|
|
114
|
-
const nodeOperands = (args) => {
|
|
115
|
-
const out = [];
|
|
116
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
117
|
-
const value = String(args[i]);
|
|
118
|
-
if (nodeValueFlags.has(value)) { i += 1; continue; }
|
|
119
|
-
if (!value.startsWith('-')) out.push(value);
|
|
120
|
-
}
|
|
121
|
-
return out;
|
|
122
|
-
};
|
|
123
|
-
const nodeEntry = (args, entries) => {
|
|
124
|
-
const script = nodeOperands(args)[0];
|
|
125
|
-
if (!script) return false;
|
|
126
|
-
const normalized = slash(script);
|
|
127
|
-
return entries.some((entry) => normalized === entry || normalized.endsWith(`/${entry}`));
|
|
128
|
-
};
|
|
129
|
-
const hasOption = (args, ...names) => args.some((value) => names.some((name) => value === name || (name.startsWith('--') && String(value).startsWith(`${name}=`)) || (name.length === 2 && name.startsWith('-') && String(value).startsWith(name))));
|
|
130
|
-
|
|
131
|
-
const nestedProgram = (program, args) => {
|
|
132
|
-
if (!['npx', 'bunx', 'corepack'].includes(program)) return null;
|
|
133
|
-
const list = args.map(String);
|
|
134
|
-
let i = 0;
|
|
135
|
-
while (i < list.length && list[i].startsWith('-')) {
|
|
136
|
-
if (['-p', '--package'].includes(list[i].split('=', 1)[0]) && !list[i].includes('=')) i += 1;
|
|
137
|
-
i += 1;
|
|
138
|
-
}
|
|
139
|
-
return list[i] ? { program: programName(list[i]), args: list.slice(i + 1), word: list[i] } : null;
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
const forbiddenOption = (args, options) => args.some((value) => options.some((option) => {
|
|
143
|
-
const arg = String(value), flag = String(option);
|
|
144
|
-
if (!flag.startsWith('-')) return arg === flag;
|
|
145
|
-
if (flag.startsWith('--')) return arg === flag || arg.startsWith(`${flag}=`);
|
|
146
|
-
return arg === flag || (flag.length === 2 && arg.startsWith(flag));
|
|
147
|
-
}));
|
|
148
|
-
|
|
149
|
-
const gitPolicyVerdict = ({ role, args, p, policy, text }) => {
|
|
150
|
-
const { sub, rest } = gitSubOf(args);
|
|
151
|
-
if (!sub || p.gitRead.has(sub)) return null;
|
|
152
|
-
if (p.gitListForms.has(sub) && gitListFormRead(sub, rest)) return null;
|
|
153
|
-
if (sub === 'push') {
|
|
154
|
-
const targets = pushTargets(rest), mayBackup = p.backupRoles.has(role);
|
|
155
|
-
if (mayBackup && p.backupRef && !targets.broad && targets.refs.length && targets.refs.every((ref) => p.backupRef.test(ref))) return null;
|
|
156
|
-
}
|
|
157
|
-
const denied = policy.git?.deny?.[sub];
|
|
158
|
-
const fallbackUse = useOf(policy.git?.deny?.merge, 'starci git sync');
|
|
159
|
-
const use = useOf(denied, fallbackUse);
|
|
160
|
-
const code = codeOf(denied, 'RIGHTS_GIT_SYNC');
|
|
161
|
-
return refusal(code, text, `the ${role} role does not run git ${sub} directly because git writes and synchronization go through the starci git verbs`, use);
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const npmReadAllowed = (args, words, verb, p) => p.npmRead.has(verb)
|
|
165
|
-
|| (verb === 'config' && p.npmRead.has(`config ${words[1] ?? ''}`))
|
|
166
|
-
|| (verb === 'pack' && args.includes('--dry-run'));
|
|
167
|
-
|
|
168
|
-
const npmPublishVerdict = (role, text, policy) => {
|
|
169
|
-
const denied = policy.npm?.deny?.publish, use = useOf(denied, 'starci release publish');
|
|
170
|
-
return refusal(codeOf(denied, 'RIGHTS_NPM_PUBLISH'), text, `the ${role} role does not publish a package because packages are published once per release from the release commit`, use);
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
const npmSuiteVerdict = ({ role, args, p, policy, guard, text, words, verb }) => {
|
|
174
|
-
if (!suiteScript(words) || afterDashDash(args).length) return undefined;
|
|
175
|
-
if (p.suiteOps.has(String(guard?.op ?? ''))) return null;
|
|
176
|
-
const use = useOf(policy.npm?.deny?.[verb === 'run' || verb === 'run-script' ? verb : 'test'], 'starci gate unit --root <app>');
|
|
177
|
-
return refusal('RIGHTS_SUITE_RUN', text, `the ${role} role does not run a whole suite because full suites run in CI or the requested verify ops`, use);
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
const npmCleanInstallVerdict = ({ args, policy, handle, lockOwner, text, verb }) => {
|
|
181
|
-
if (!cleanInstall(verb)) return undefined;
|
|
182
|
-
if (args.includes('--dry-run')) return null;
|
|
183
|
-
let owner = null;
|
|
184
|
-
try { owner = lockOwner(); } catch { /* an unreadable lock proves no ownership */ }
|
|
185
|
-
const owns = (held) => !held.stale && (held.role === 'release' || (handle && held.handle === handle));
|
|
186
|
-
if (owner && owns(owner)) return null;
|
|
187
|
-
const denied = policy.npm?.deny?.ci, use = useOf(denied, 'the runtime installs dependencies under the host lock');
|
|
188
|
-
return refusal(codeOf(denied, 'RIGHTS_NPM_CI_UNLOCKED'), text, 'this clean install rewrites node_modules without the caller holding the host lock', use);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
const npmPolicyVerdict = ({ role, args, p, policy, guard, handle, lockOwner, text }) => {
|
|
192
|
-
const words = packageWords(args), verb = words[0] ?? '';
|
|
193
|
-
if (args.length === 1 && ['-v', '--version'].includes(args[0])) return null;
|
|
194
|
-
if (npmReadAllowed(args, words, verb, p)) return null;
|
|
195
|
-
if (publishScript(words)) return npmPublishVerdict(role, text, policy);
|
|
196
|
-
const suite = npmSuiteVerdict({ role, args, p, policy, guard, text, words, verb });
|
|
197
|
-
if (suite !== undefined) return suite;
|
|
198
|
-
const install = npmCleanInstallVerdict({ args, policy, handle, lockOwner, text, verb });
|
|
199
|
-
if (install !== undefined) return install;
|
|
200
|
-
if (suiteScript(words) && afterDashDash(args).length) return null;
|
|
201
|
-
const denied = policy.npm?.deny?.[verb] ?? policy.npm?.deny?.run;
|
|
202
|
-
const use = useOf(denied, 'starci gate run --root <app>');
|
|
203
|
-
return refusal(codeOf(denied, 'RIGHTS_RAW_TOOL'), text, `the ${role} role does not run this package-manager action directly because scripts and dependency changes go through a starci verb`, use);
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const SERVES_APP = /(?:^|[\\/])next(?:\.js)?$/;
|
|
207
|
-
|
|
208
|
-
const nodePolicyVerdict = ({ role, args, p, policy, guard, text }) => {
|
|
209
|
-
if (hasOption(args, '--eval', '-e', '--print', '-p')) return raw(role, text, 'use the starci verb of the action or check a file with node --check', 'inline Node.js program');
|
|
210
|
-
if (args.length === 1 && ['--version', '-v'].includes(args[0])) return null;
|
|
211
|
-
if (hasOption(args, '--check', '-c')) return null;
|
|
212
|
-
if (args.includes('--test')) {
|
|
213
|
-
if (nodeWholeSuite(args)) {
|
|
214
|
-
if (p.suiteOps.has(String(guard?.op ?? ''))) return null;
|
|
215
|
-
return refusal('RIGHTS_SUITE_RUN', text, `the ${role} role does not run a whole suite because full suites run in CI or the requested verify ops`, 'starci runtime verify in the runtime (the check, package clean-test and scaffold e2e), or one explicit spec file');
|
|
216
|
-
}
|
|
217
|
-
const targets = nodeOperands(args).filter((value) => value !== 'test');
|
|
218
|
-
if (p.nodeTest.has(role) && targets.length && targets.every((value) => /\.(?:spec|test)\.[cm]?js$/i.test(value))) return null;
|
|
219
|
-
return raw(role, text, 'starci gate unit --root <app> (the op gate selects the specs of the change)', 'raw test runner');
|
|
220
|
-
}
|
|
221
|
-
if (nodeOperands(args).some((value) => SERVES_APP.test(slash(value)))) return raw(role, text, useOf(policy['raw-tools']?.next, genericUse), 'app server start');
|
|
222
|
-
return raw(role, text, genericUse, 'Node.js script');
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
const orcaPolicyVerdict = ({ role, args, p, policy, guard, handle, text }) => {
|
|
226
|
-
const [group, verb] = args;
|
|
227
|
-
const mailbox = kernelMailboxVerdict('orca', args, guard);
|
|
228
|
-
if (mailbox) return mailbox;
|
|
229
|
-
const addressed = orcaAddressedOption({ role, args, policy });
|
|
230
|
-
if (addressed) return refusal('OP_REPORTS_TO_KERNEL', text, `an Op reports only to its Kernel: ${addressed} addresses another recipient`, rolesContract().chain.opRefusal.use);
|
|
231
|
-
if (orcaSelfLifecycleAllowed({ role, args, handle, policy })) return null;
|
|
232
|
-
// Implicit current-terminal checks retain their existing read admission; an explicit target must prove self.
|
|
233
|
-
const targetedCheck = group === 'orchestration' && verb === 'check' && hasOption(args.slice(2), '--terminal');
|
|
234
|
-
if (!targetedCheck && p.orcaGroups.has(group) && p.orcaRead.has(verb)) return null;
|
|
235
|
-
return raw(role, text, useOf(policy.orca, genericUse), 'Orca mutation');
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
const specificCommandVerdict = (context) => {
|
|
239
|
-
const { role, program, args, p, policy, guard, handle, lockOwner, text } = context;
|
|
240
|
-
if (program === 'git') return gitPolicyVerdict({ role, args, p, policy, text });
|
|
241
|
-
if (p.npmPrograms.has(program)) return npmPolicyVerdict({ role, args, p, policy, guard, handle, lockOwner, text });
|
|
242
|
-
if (program === 'node') return nodePolicyVerdict({ role, args, p, policy, guard, text });
|
|
243
|
-
if (program === 'orca') return orcaPolicyVerdict({ role, args, p, policy, guard, handle, text });
|
|
244
|
-
return undefined;
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const generalCommandVerdict = ({ role, program, args, p, policy, text }) => {
|
|
248
|
-
const rawTool = policy['raw-tools']?.[program];
|
|
249
|
-
if (rawTool) return raw(role, text, useOf(rawTool, genericUse));
|
|
250
|
-
const guarded = policy['guarded-read']?.[program];
|
|
251
|
-
if (Array.isArray(guarded)) {
|
|
252
|
-
const use = `use the read-only form of ${program}`;
|
|
253
|
-
if (forbiddenOption(args, guarded)) return raw(role, text, use, `${program} write form`);
|
|
254
|
-
const required = policy['require-flag']?.[program];
|
|
255
|
-
if (Array.isArray(required) && !required.some((flag) => args.includes(String(flag)))) return raw(role, text, use, `${program} emitting form`);
|
|
256
|
-
return null;
|
|
257
|
-
}
|
|
258
|
-
if (p.read.has(program) || p.scopedWrite.has(program)) return null;
|
|
259
|
-
return raw(role, text);
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
/** The Critic's verdict: the Orca self-lifecycle verbs, else only what critic-reach.mjs lets it read and write. */
|
|
263
|
-
function criticPolicyVerdict({ program, args, command, guard, handle, policy }) {
|
|
264
|
-
if (program === 'orca' && orcaSelfLifecycleAllowed({ role: 'critic', args, handle, policy })) return null;
|
|
265
|
-
return criticCommandVerdict({ program, args, cwd: command.cwd, guard, critic: policy.critic, text: textOf({ ...command, program, args }) });
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The verdict of the seat tables (Supervisor, Op, Kernel). Undefined when the general policy decides: a Kernel seat is decided here in
|
|
270
|
-
* full (null passes), a Supervisor or Op only when its table refuses.
|
|
271
|
-
*/
|
|
272
|
-
function seatVerdict({ role, guard, handle, policy, program, args, text }) {
|
|
273
|
-
if (guard?.role === 'kernel') return kernelSeatVerdict({ policy, program, args, text, guard, handle });
|
|
274
|
-
if (role === 'supervisor') return supervisorSeatVerdict({ policy, program, args, text }) ?? undefined;
|
|
275
|
-
if (role === 'op') return opStarciVerdict({ policy, program, args, text }) ?? undefined;
|
|
276
|
-
return undefined;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Decide one normalized command for a bound role. Returns null to pass or the shared refusal shape.
|
|
281
|
-
* `lockOwner` is a synchronous reader and is called only for a clean install.
|
|
282
|
-
*/
|
|
283
|
-
export function policyVerdict({ role, command, guard = null, handle = null, lockOwner = () => null, policy }) {
|
|
284
|
-
const p = compile(policy);
|
|
285
|
-
if (!p || !role || role === 'release' || !p.bound.has(role)) return null;
|
|
286
|
-
let { program, args = [] } = command;
|
|
287
|
-
program = programName(program);
|
|
288
|
-
args = args.map(String);
|
|
289
|
-
if (role === 'critic') return criticPolicyVerdict({ program, args, command, guard, handle, policy });
|
|
290
|
-
const nested = nestedProgram(program, args);
|
|
291
|
-
if (nested) return policyVerdict({ role, command: { ...nested, cwd: command.cwd }, guard, handle, lockOwner, policy });
|
|
292
|
-
const text = textOf({ ...command, program, args });
|
|
293
|
-
|
|
294
|
-
if (releaseCall(program, args, policy)) {
|
|
295
|
-
const use = useOf(policy.release, 'starci release cut');
|
|
296
|
-
return refusal('RIGHTS_RELEASE_CUT', text, `the ${role} role does not cut or publish a release because tagging a release and publishing packages are owner-approved`, use);
|
|
297
|
-
}
|
|
298
|
-
const seat = seatVerdict({ role, guard, handle, policy, program, args, text });
|
|
299
|
-
if (seat !== undefined) return seat;
|
|
300
|
-
const call = callVerdict({ role, program, args, guard, policy, text });
|
|
301
|
-
if (call) return call;
|
|
302
|
-
if (p.runtime.has(program)) return null;
|
|
303
|
-
if (program === 'node' && nodeEntry(args, p.nodeScripts)) return null;
|
|
304
|
-
|
|
305
|
-
const specific = specificCommandVerdict({ role, program, args, p, policy, guard, handle, lockOwner, text });
|
|
306
|
-
if (specific !== undefined) return specific;
|
|
307
|
-
return generalCommandVerdict({ role, program, args, p, policy, text });
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const needsLock = (program, args, env) => {
|
|
311
|
-
if (String(env?.STARCI_ROLE ?? '').toLowerCase() === 'release') return true;
|
|
312
|
-
const nested = nestedProgram(program, args);
|
|
313
|
-
if (nested) return needsLock(nested.program, nested.args, env);
|
|
314
|
-
return ['npm', 'pnpm', 'yarn', 'bun'].includes(program) && cleanInstall(packageWords(args)[0] ?? '');
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
/** Decide one direct PATH-shim invocation without importing the PreToolUse parser or guard. */
|
|
318
|
-
export async function shimDecision({ program, args = [], cwd = process.cwd(), env = process.env, root = SKILL_ROOT } = {}) {
|
|
319
|
-
const normalized = programName(program), argv = args.map(String), handle = env?.ORCA_TERMINAL_HANDLE ?? null;
|
|
320
|
-
let lockOwner = () => null;
|
|
321
|
-
if (needsLock(normalized, argv, env)) {
|
|
322
|
-
try {
|
|
323
|
-
const lock = await import('../machine/host-lock.mjs');
|
|
324
|
-
lockOwner = () => { try { return lock.hostLockOwner({ env }); } catch { return null; } };
|
|
325
|
-
} catch { /* a missing lock reader proves neither release nor install ownership */ }
|
|
326
|
-
}
|
|
327
|
-
const guard = boundGuard(handle, { root, env });
|
|
328
|
-
const seat = boundSeat(handle, { root, env });
|
|
329
|
-
const role = rightsRoleOf({ guard, seat, env, lockOwner: String(env?.STARCI_ROLE ?? '').toLowerCase() === 'release' ? lockOwner() : null });
|
|
330
|
-
const policy = role && role !== 'release' ? loadCommandPolicy({ root }) : null;
|
|
331
|
-
const verdict = policyVerdict({ role, command: { program: normalized, args: argv, cwd, word: normalized }, guard, handle, lockOwner, policy });
|
|
332
|
-
return { role, verdict };
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Format a shim refusal exactly like the PreToolUse hook. */
|
|
336
|
-
export function shimRefusalLines(verdict) {
|
|
337
|
-
const command = String(verdict?.command ?? '').trim();
|
|
338
|
-
const at = command.indexOf(' ');
|
|
339
|
-
const tool = at < 0 ? command || 'shell' : command.slice(0, at);
|
|
340
|
-
const tail = at < 0 ? '' : command.slice(at + 1);
|
|
341
|
-
return refusalLines(tool, { ...verdict, command: tail });
|
|
342
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// critic-reach.mjs — what the Critic (rights role `critic`) may reach, enforced and not described. Its job guard names one
|
|
2
|
-
// directory (`reach.dir`: the product files and the rubric handed to it) and one file (`reach.verdictFile`: its answer). The
|
|
3
|
-
// shell may run only the read-only programs modules/kernel/command-policy.yaml `critic.read` lists, on paths inside that
|
|
4
|
-
// directory, and the writers it lists only onto the verdict file; the Orca self-lifecycle verbs (worker_done, escalation) stay.
|
|
5
|
-
// The Edit and Write tools reach the verdict file only; on Claude and Devin the Read, Grep and Glob tools reach the directory only.
|
|
6
|
-
// Every other command, path or tool is refused with RIGHTS_CRITIC_REACH.
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import { pathKey, sameOrUnder, slash } from '../lib/path-key.mjs';
|
|
9
|
-
import { refusal } from './rights.mjs';
|
|
10
|
-
|
|
11
|
-
const CRITIC_REACH_CODE = 'RIGHTS_CRITIC_REACH';
|
|
12
|
-
const USE = 'read only the files in your own directory and write only the verdict file there; report with worker_done or an escalation';
|
|
13
|
-
const OPTION_PATH = /^-(?:path|filepath|literalpath)$/i;
|
|
14
|
-
// Programs whose operands are text, not paths: only an expansion could smuggle a path into them.
|
|
15
|
-
const TEXT_PROGRAMS = new Set(['echo', 'printf', 'write-output', 'write-host']);
|
|
16
|
-
|
|
17
|
-
const deny = (text, reason) => refusal(CRITIC_REACH_CODE, text, reason, USE, USE);
|
|
18
|
-
const inside = (file, dir) => sameOrUnder(pathKey(path.resolve(file)), pathKey(path.resolve(dir)));
|
|
19
|
-
const same = (a, b) => pathKey(path.resolve(a)) === pathKey(path.resolve(b));
|
|
20
|
-
/** Readable by the Critic: its directory, and the one Task file the runtime wrote for it outside that directory (reach.taskFile). */
|
|
21
|
-
const readable = (file, guard) => inside(file, guard.reach.dir) || (typeof guard.reach.taskFile === 'string' && same(file, guard.reach.taskFile));
|
|
22
|
-
const expands = (value, program) => /[$`]/.test(value) || (!TEXT_PROGRAMS.has(program) && (/%/.test(value) || String(value).startsWith('~')));
|
|
23
|
-
|
|
24
|
-
/** The verdict file of the guard, absolute. */
|
|
25
|
-
const verdictPathOf = (guard) => path.resolve(guard.reach.dir, guard.reach.verdictFile);
|
|
26
|
-
|
|
27
|
-
/** The operands that name paths in `args`: the value of `--opt=value`, every non-option word. */
|
|
28
|
-
function pathWords(args) {
|
|
29
|
-
return args.map(String).flatMap((word) => {
|
|
30
|
-
if (!word.startsWith('-')) return [word];
|
|
31
|
-
const at = word.indexOf('=');
|
|
32
|
-
return at < 0 ? [] : [word.slice(at + 1)];
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** The words of a read program outside the critic's directory, or an expansion no check can resolve. */
|
|
37
|
-
function readReach({ program, args, cwd, guard }) {
|
|
38
|
-
const dir = guard.reach.dir;
|
|
39
|
-
if (!inside(cwd, dir)) return cwd;
|
|
40
|
-
const words = TEXT_PROGRAMS.has(program) ? args.map(String).filter((word) => expands(word, program)) : pathWords(args);
|
|
41
|
-
return words.find((word) => expands(word, program) || (!TEXT_PROGRAMS.has(program) && !readable(path.resolve(cwd, slash(word)), guard))) ?? null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** The target path a writer program names: the -Path/-FilePath value, else its first non-option word. */
|
|
45
|
-
function writeTarget(args) {
|
|
46
|
-
const list = args.map(String);
|
|
47
|
-
const named = list.findIndex((word) => OPTION_PATH.test(word));
|
|
48
|
-
if (named >= 0) return list[named + 1] ?? null;
|
|
49
|
-
return list.find((word) => !word.startsWith('-')) ?? null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The refusal of one shell command of a Critic, or null. `critic` is the policy section (read[], write[]), `text` the
|
|
54
|
-
* command as written. Orca lifecycle verbs are judged before this by the caller.
|
|
55
|
-
*/
|
|
56
|
-
export function criticCommandVerdict({ program, args, cwd, guard, critic, text }) {
|
|
57
|
-
const dir = guard?.reach?.dir;
|
|
58
|
-
if (!dir) return deny(text, 'the critic guard names no directory, so nothing is reachable');
|
|
59
|
-
const base = cwd ?? dir;
|
|
60
|
-
if ((critic?.write ?? []).includes(program)) {
|
|
61
|
-
const target = writeTarget(args);
|
|
62
|
-
return target && !expands(target, program) && same(path.resolve(base, target), verdictPathOf(guard)) ? null
|
|
63
|
-
: deny(text, `the critic writes only ${guard.reach.verdictFile} in its directory`);
|
|
64
|
-
}
|
|
65
|
-
if (!(critic?.read ?? []).includes(program)) return deny(text, `the critic runs only read-only commands (${program} is not one of them)`);
|
|
66
|
-
const outside = readReach({ program, args: args.map(String), cwd: base, guard });
|
|
67
|
-
return outside === null ? null : deny(text, `the critic reads only inside its own directory (${outside} is not)`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The refusal of a write to `filePath` (a file tool or a shell redirection target) by a Critic, or null. */
|
|
71
|
-
export function criticWriteVerdict({ filePath, guard, tool = 'shell' }) {
|
|
72
|
-
if (!guard?.reach?.dir) return deny(filePath, 'the critic guard names no directory, so nothing is writable');
|
|
73
|
-
return same(filePath, verdictPathOf(guard)) ? null : deny(filePath, `the critic writes only ${guard.reach.verdictFile} (${tool} targeted another path)`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The refusal of a read tool call (Read, Grep, Glob) of a Critic, or null: `paths` are the absolute paths the call reaches. */
|
|
77
|
-
export function criticReadVerdict({ paths, guard, tool }) {
|
|
78
|
-
const dir = guard?.reach?.dir;
|
|
79
|
-
if (!dir) return deny(tool, 'the critic guard names no directory, so nothing is readable');
|
|
80
|
-
const outside = paths.find((one) => !readable(one, guard));
|
|
81
|
-
return outside ? deny(`${tool} ${outside}`, `the critic reads only inside its own directory (${tool} reached ${outside})`) : null;
|
|
82
|
-
}
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
// deps-guard.mjs — dependency installs in a checkout several workflows share.
|
|
2
|
-
//
|
|
3
|
-
// A shared checkout's node_modules was deleted and recreated while a
|
|
4
|
-
// workflow's cut ran jest and tsc in the same checkout; one of its workers saw
|
|
5
|
-
// "node_modules disappeared mid-run" and every check in that window had to be
|
|
6
|
-
// re-run. The rule (modules/kernel/api.yaml conventions.sharedCheckout):
|
|
7
|
-
// - a command that deletes node_modules (npm ci and its aliases) is refused
|
|
8
|
-
// while a job of ANOTHER workflow of the same ledger is leased, because that
|
|
9
|
-
// job's checks read node_modules right now;
|
|
10
|
-
// - an install-family command of npm, pnpm or yarn through a linked node_modules is always refused, with or without
|
|
11
|
-
// a guard file: it empties the live tree the link points to (node-modules-link-wipe, contract change
|
|
12
|
-
// install-through-link).
|
|
13
|
-
// scripts/guards/command-guard.mjs (a PreToolUse hook) applies it to an agent's shell command before it runs.
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
|
|
17
|
-
/** The package managers whose install family rewrites node_modules. */
|
|
18
|
-
export const PACKAGE_MANAGERS = Object.freeze(['npm', 'pnpm', 'yarn']);
|
|
19
|
-
const NPM_INSTALL = new Set(['install', 'i', 'in', 'ins', 'inst', 'insta', 'instal', 'isnt', 'isnta', 'isntal', 'isntall', 'add',
|
|
20
|
-
'uninstall', 'un', 'unlink', 'remove', 'rm', 'r', 'update', 'up', 'upgrade', 'udpate', 'prune', 'dedupe', 'ddp', 'rebuild', 'rb', 'link', 'ln',
|
|
21
|
-
'it', 'install-test']);
|
|
22
|
-
const NPM_CLEAN_INSTALL = new Set(['ci', 'clean-install', 'ic', 'install-clean', 'isntall-clean', 'cit', 'install-ci-test', 'clean-install-test', 'sit']);
|
|
23
|
-
const PNPM_INSTALL = new Set(['install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un', 'update', 'up', 'upgrade', 'prune', 'rebuild', 'rb',
|
|
24
|
-
'link', 'ln', 'unlink', 'dedupe', 'import', 'install-test', 'it']);
|
|
25
|
-
const YARN_INSTALL = new Set(['install', 'add', 'remove', 'upgrade', 'up', 'upgrade-interactive', 'dedupe', 'link', 'unlink', 'import']);
|
|
26
|
-
// Options that consume the next word, per manager.
|
|
27
|
-
const VALUE_OPTIONS = {
|
|
28
|
-
npm: new Set(['--prefix', '-C', '--dir', '--workspace', '-w', '--userconfig', '--cache', '--registry', '--loglevel', '--tag', '--omit', '--include', '--install-strategy']),
|
|
29
|
-
pnpm: new Set(['-C', '--dir', '--prefix', '--filter', '-F', '--workspace-dir', '--store-dir', '--virtual-store-dir', '--reporter', '--loglevel', '--registry', '--config']),
|
|
30
|
-
yarn: new Set(['--cwd', '--modules-folder', '--cache-folder', '--mutex', '--network-timeout', '--registry', '--global-folder', '--link-folder']),
|
|
31
|
-
};
|
|
32
|
-
// The options that name the package root an install works in.
|
|
33
|
-
const ROOT_OPTIONS = ['--prefix', '-C', '--dir', '--cwd'];
|
|
34
|
-
|
|
35
|
-
const optionValue = (args, names) => {
|
|
36
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
37
|
-
const a = args[i];
|
|
38
|
-
if (names.includes(a) && args[i + 1] != null) return args[i + 1];
|
|
39
|
-
const eq = names.find((n) => n.startsWith('--') && a.startsWith(`${n}=`));
|
|
40
|
-
if (eq) return a.slice(eq.length + 1);
|
|
41
|
-
}
|
|
42
|
-
return null;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
/** The package root an install of `argv` works in: --prefix / -C / --dir / --cwd, else the nearest package.json above `cwd`. */
|
|
46
|
-
export function packageRootOf(argv, cwd = process.cwd()) {
|
|
47
|
-
const given = optionValue(argv.map(String), ROOT_OPTIONS);
|
|
48
|
-
if (given) return path.resolve(cwd, given);
|
|
49
|
-
for (let d = path.resolve(cwd); ; d = path.dirname(d)) {
|
|
50
|
-
if (fs.existsSync(path.join(d, 'package.json'))) return d;
|
|
51
|
-
if (path.dirname(d) === d) return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// `nm` when it is a link (a junction or symlink, or a directory whose real path is not its own place), else null.
|
|
56
|
-
function linkAt(nm) {
|
|
57
|
-
let st; try { st = fs.lstatSync(nm); } catch { return null; }
|
|
58
|
-
let linked = st.isSymbolicLink();
|
|
59
|
-
if (!linked && st.isDirectory()) {
|
|
60
|
-
try { linked = path.resolve(fs.realpathSync.native(nm)).toLowerCase() !== path.join(fs.realpathSync.native(path.dirname(nm)), path.basename(nm)).toLowerCase(); }
|
|
61
|
-
catch { linked = true; }
|
|
62
|
-
}
|
|
63
|
-
if (!linked) return null;
|
|
64
|
-
let target = null; try { target = fs.realpathSync.native(nm); } catch { /* dangling */ }
|
|
65
|
-
return { nodeModules: nm, target };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// The workspace root above `root` (a package.json with "workspaces", or a pnpm-workspace.yaml), whose node_modules a
|
|
69
|
-
// workspace install also writes; null when there is none.
|
|
70
|
-
function workspaceRootAbove(root) {
|
|
71
|
-
for (let d = path.dirname(root); path.dirname(d) !== d; d = path.dirname(d)) {
|
|
72
|
-
if (fs.existsSync(path.join(d, 'pnpm-workspace.yaml'))) return d;
|
|
73
|
-
try { if (JSON.parse(fs.readFileSync(path.join(d, 'package.json'), 'utf8'))?.workspaces) return d; } catch { /* none or unreadable */ }
|
|
74
|
-
}
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The node_modules an install of `program argv` in `cwd` would rewrite that is a LINK (junction/symlink), or null. npm
|
|
80
|
-
* reifies through a linked node_modules and empties its target: proven 2026-09-28 (npm 11.6, "Removing non-directory
|
|
81
|
-
* node_modules" left the junction's target empty), and the wipes kept coming (the runtime's live .claude/node_modules;
|
|
82
|
-
* 2026-10-01 a lane's `npm ci` emptied main's packages/grammar/node_modules through its junction). Checked in order:
|
|
83
|
-
* the package root's node_modules (yarn --modules-folder when given), every node_modules the root itself sits inside,
|
|
84
|
-
* and the workspace root's node_modules. A node_modules that does not exist yet is no link. Never throws.
|
|
85
|
-
*/
|
|
86
|
-
export function linkedNodeModulesOf(program, argv, cwd = process.cwd()) {
|
|
87
|
-
try {
|
|
88
|
-
const args = argv.map(String);
|
|
89
|
-
const root = packageRootOf(args, cwd);
|
|
90
|
-
if (!root) return null;
|
|
91
|
-
const modulesFolder = program === 'yarn' ? optionValue(args, ['--modules-folder']) : null;
|
|
92
|
-
const candidates = [modulesFolder ? path.resolve(root, modulesFolder) : path.join(root, 'node_modules')];
|
|
93
|
-
for (let d = root; path.dirname(d) !== d; d = path.dirname(d)) if (path.basename(d).toLowerCase() === 'node_modules') candidates.push(d);
|
|
94
|
-
const ws = workspaceRootAbove(root);
|
|
95
|
-
if (ws) candidates.push(path.join(ws, 'node_modules'));
|
|
96
|
-
for (const nm of candidates) { const hit = linkAt(nm); if (hit) return hit; }
|
|
97
|
-
return null;
|
|
98
|
-
} catch { return null; }
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** The install kind for a package manager's subcommand (npm/pnpm/yarn membership tables). */
|
|
102
|
-
const installKindOf = (program, sub) => {
|
|
103
|
-
if (program === 'npm') {
|
|
104
|
-
if (NPM_CLEAN_INSTALL.has(sub)) return 'clean-install';
|
|
105
|
-
return sub && NPM_INSTALL.has(sub) ? 'install' : 'pass';
|
|
106
|
-
}
|
|
107
|
-
if (program === 'pnpm') return sub && PNPM_INSTALL.has(sub) ? 'install' : 'pass';
|
|
108
|
-
// yarn with no subcommand installs; any other word runs a script.
|
|
109
|
-
return !sub || YARN_INSTALL.has(sub) ? 'install' : 'pass';
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
/** classifyInstall(program, argv) -> {kind: 'pass'|'install'|'clean-install', sub}: whether the command rewrites node_modules. */
|
|
113
|
-
export function classifyInstall(program, argv) {
|
|
114
|
-
if (!PACKAGE_MANAGERS.includes(program)) return { kind: 'pass', sub: null };
|
|
115
|
-
const args = argv.map(String);
|
|
116
|
-
const valued = VALUE_OPTIONS[program];
|
|
117
|
-
let sub = null;
|
|
118
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
119
|
-
const a = args[i];
|
|
120
|
-
if (valued.has(a)) { i += 1; continue; }
|
|
121
|
-
if (a.startsWith('-')) continue;
|
|
122
|
-
sub = a;
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
const global = args.some((a) => a === '-g' || a === '--global' || a === '--location=global') || (program === 'yarn' && sub === 'global');
|
|
126
|
-
const dry = args.some((a) => a === '--dry-run' || a === '--lockfile-only');
|
|
127
|
-
if (global || dry) return { kind: 'pass', sub };
|
|
128
|
-
return { kind: installKindOf(program, sub), sub };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Jobs of OTHER workflows of this ledger that hold a lease right now (read-only). */
|
|
132
|
-
export async function peerLeasedJobs({ ledgerRepo, workflowId, env = process.env, now = Date.now() }) {
|
|
133
|
-
if (!ledgerRepo) return { known: false, jobs: [] };
|
|
134
|
-
const { openLedgerReader, ledgerFileFor } = await import('../../engine/db/ledger.mjs');
|
|
135
|
-
// The repo's runtime ledger is the one file ledgerFileFor resolves (machine.ledgers names it); there is no other place to look.
|
|
136
|
-
let file = null;
|
|
137
|
-
try {
|
|
138
|
-
const resolved = ledgerFileFor(ledgerRepo, { env });
|
|
139
|
-
if (fs.existsSync(resolved)) file = resolved;
|
|
140
|
-
} catch { file = null; }
|
|
141
|
-
if (!file) return { known: false, jobs: [] };
|
|
142
|
-
const db = openLedgerReader(file);
|
|
143
|
-
try {
|
|
144
|
-
db.exec('PRAGMA busy_timeout=5000');
|
|
145
|
-
const rows = db.prepare(`SELECT j.job_id, j.workflow_id, j.op_id, j.status FROM jobs j
|
|
146
|
-
WHERE j.kind<>'kernel' AND j.status IN ('leased','running','answering') AND j.workflow_id<>?`).all(workflowId ?? '');
|
|
147
|
-
return { known: true, jobs: rows.map((r) => ({ jobId: r.job_id, workflowId: r.workflow_id, opId: r.op_id, status: r.status })) };
|
|
148
|
-
} finally { db.close(); }
|
|
149
|
-
}
|
|
150
|
-
|