@starci/hfs 4.8.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/emit/operation-schemas.mjs +3 -0
  3. package/lint/bound-sys.cjs +2 -2
  4. package/package.json +1 -1
  5. package/runtime/engine/admission.mjs +1 -1
  6. package/runtime/engine/config.mjs +2 -2
  7. package/runtime/engine/db/machine.mjs +2 -3
  8. package/runtime/engine/invalid-config.mjs +1 -1
  9. package/runtime/knowledge/hfs/canon-pins.yaml +11 -10
  10. package/runtime/knowledge/hfs/rules.yaml +23 -0
  11. package/runtime/modules/kernel/failure-codes.yaml +10 -0
  12. package/runtime/scripts/guards/git-policy.mjs +2 -3
  13. package/runtime/scripts/hfs/rules/pipeline.mjs +5 -5
  14. package/runtime/scripts/lib/package-at.mjs +5 -6
  15. package/runtime/scripts/lib/roots.mjs +2 -2
  16. package/runtime/scripts/work/draw/draw-dna-findings.mjs +23 -12
  17. package/runtime/scripts/work/draw/draw-dna.mjs +14 -8
  18. package/scaffold/app.mjs +4 -4
  19. package/src/main.mjs +3 -3
  20. package/sync/index.mjs +14 -1
  21. package/sync/managed.mjs +3 -2
  22. package/templates/app/codegen-graphql.mjs.partial +4 -4
  23. package/templates/app/quality-config/sonar-project.properties +4 -2
  24. package/runtime/engine/canonical-json.mjs +0 -14
  25. package/runtime/engine/db/ledger-open.mjs +0 -38
  26. package/runtime/engine/db/ledger-paths.mjs +0 -87
  27. package/runtime/engine/db/ledger.mjs +0 -1068
  28. package/runtime/engine/db/provision-authority.mjs +0 -76
  29. package/runtime/engine/db/provision-operations.mjs +0 -146
  30. package/runtime/packages/grammar/scripts/build-stamp.mjs +0 -98
  31. package/runtime/scripts/agent/codex-app-server.mjs +0 -79
  32. package/runtime/scripts/agent/codex-mirror-source.mjs +0 -34
  33. package/runtime/scripts/agent/host-agents.mjs +0 -80
  34. package/runtime/scripts/agent/launch-trust-policy.mjs +0 -53
  35. package/runtime/scripts/agent/trust-launch.mjs +0 -191
  36. package/runtime/scripts/agent/trust.mjs +0 -523
  37. package/runtime/scripts/api/fs/temp-path.mjs +0 -8
  38. package/runtime/scripts/api/git/diff-names.mjs +0 -8
  39. package/runtime/scripts/api/git/head-time.mjs +0 -10
  40. package/runtime/scripts/api/git/index-lock.mjs +0 -78
  41. package/runtime/scripts/api/git/log.mjs +0 -5
  42. package/runtime/scripts/api/git/merge-tree.mjs +0 -5
  43. package/runtime/scripts/api/git/rev-list.mjs +0 -5
  44. package/runtime/scripts/api/git/rev-parse.mjs +0 -13
  45. package/runtime/scripts/api/git/status-query.mjs +0 -5
  46. package/runtime/scripts/api/git/worktree-list-query.mjs +0 -5
  47. package/runtime/scripts/api/node/spawn-node.mjs +0 -6
  48. package/runtime/scripts/api/npm/dist-tag.mjs +0 -8
  49. package/runtime/scripts/api/npm/lib.mjs +0 -32
  50. package/runtime/scripts/api/npm/pack-dry-run.mjs +0 -9
  51. package/runtime/scripts/api/npm/run-npm.mjs +0 -6
  52. package/runtime/scripts/api/npm/view.mjs +0 -30
  53. package/runtime/scripts/api/npm/whoami.mjs +0 -8
  54. package/runtime/scripts/api/process/hide-child-windows.mjs +0 -31
  55. package/runtime/scripts/api/process/kill-tree.mjs +0 -22
  56. package/runtime/scripts/api/process/lib.mjs +0 -125
  57. package/runtime/scripts/api/process/probe-guard-command.mjs +0 -74
  58. package/runtime/scripts/api/process/process-list.mjs +0 -28
  59. package/runtime/scripts/api/process/system-tool.mjs +0 -37
  60. package/runtime/scripts/gates/canon-digest.mjs +0 -81
  61. package/runtime/scripts/gates/canon-pins.mjs +0 -119
  62. package/runtime/scripts/gates/gate-input.mjs +0 -98
  63. package/runtime/scripts/gates/gate.mjs +0 -636
  64. package/runtime/scripts/gates/grammar-dist.mjs +0 -206
  65. package/runtime/scripts/gates/output.mjs +0 -40
  66. package/runtime/scripts/gates/runtime-package-selection.mjs +0 -27
  67. package/runtime/scripts/gates/test-world-run.mjs +0 -252
  68. package/runtime/scripts/gates/type-impact.mjs +0 -277
  69. package/runtime/scripts/gates/type-program.mjs +0 -113
  70. package/runtime/scripts/guards/assigned-command.mjs +0 -12
  71. package/runtime/scripts/guards/call-rights.mjs +0 -47
  72. package/runtime/scripts/guards/command-guard.mjs +0 -468
  73. package/runtime/scripts/guards/command-policy.mjs +0 -342
  74. package/runtime/scripts/guards/critic-reach.mjs +0 -82
  75. package/runtime/scripts/guards/deps-guard.mjs +0 -150
  76. package/runtime/scripts/guards/env-dump-verdict.mjs +0 -89
  77. package/runtime/scripts/guards/guards-root.mjs +0 -27
  78. package/runtime/scripts/guards/hook-install.mjs +0 -343
  79. package/runtime/scripts/guards/hook-verdict.mjs +0 -12
  80. package/runtime/scripts/guards/install-verdict.mjs +0 -41
  81. package/runtime/scripts/guards/kernel-seat.mjs +0 -63
  82. package/runtime/scripts/guards/launch-verdict.mjs +0 -74
  83. package/runtime/scripts/guards/op-starci.mjs +0 -16
  84. package/runtime/scripts/guards/op-write-scope.mjs +0 -27
  85. package/runtime/scripts/guards/orca-self-lifecycle.mjs +0 -39
  86. package/runtime/scripts/guards/process-kill-verdict.mjs +0 -106
  87. package/runtime/scripts/guards/protected-zone.mjs +0 -56
  88. package/runtime/scripts/guards/refusals.mjs +0 -27
  89. package/runtime/scripts/guards/rights.mjs +0 -268
  90. package/runtime/scripts/guards/seat-tools.mjs +0 -47
  91. package/runtime/scripts/guards/shell-commands.mjs +0 -328
  92. package/runtime/scripts/guards/supervisor-seat.mjs +0 -80
  93. package/runtime/scripts/guards/verdict-process.mjs +0 -81
  94. package/runtime/scripts/lib/app-data.mjs +0 -11
  95. package/runtime/scripts/lib/canon-binding-text.mjs +0 -129
  96. package/runtime/scripts/lib/check-format.mjs +0 -7
  97. package/runtime/scripts/lib/failure-code-reads.mjs +0 -100
  98. package/runtime/scripts/lib/failure-code-template-errors.mjs +0 -161
  99. package/runtime/scripts/lib/git-dir.mjs +0 -120
  100. package/runtime/scripts/lib/guard-command.mjs +0 -24
  101. package/runtime/scripts/lib/interrupt-cleanup.mjs +0 -38
  102. package/runtime/scripts/lib/json-formats.mjs +0 -34
  103. package/runtime/scripts/lib/managed-json.mjs +0 -51
  104. package/runtime/scripts/lib/menu-parts.mjs +0 -11
  105. package/runtime/scripts/lib/op-gate.mjs +0 -15
  106. package/runtime/scripts/lib/provision-control-failure.mjs +0 -32
  107. package/runtime/scripts/lib/release-version.mjs +0 -134
  108. package/runtime/scripts/lib/runtime-location.mjs +0 -21
  109. package/runtime/scripts/lib/runtime-package-inventory.mjs +0 -21
  110. package/runtime/scripts/lib/runtime-package-manifests.mjs +0 -84
  111. package/runtime/scripts/lib/runtime-publication-tags.mjs +0 -56
  112. package/runtime/scripts/lib/seat-env.mjs +0 -43
  113. package/runtime/scripts/lib/sonar-new-code.mjs +0 -58
  114. package/runtime/scripts/lib/source-package-imports.mjs +0 -111
  115. package/runtime/scripts/lib/tar-files.mjs +0 -39
  116. package/runtime/scripts/lib/uat-run-folder.mjs +0 -118
  117. package/runtime/scripts/lib/worktree-record.mjs +0 -14
  118. package/runtime/scripts/machine/ask-receipts.mjs +0 -113
  119. package/runtime/scripts/machine/evidence-store.mjs +0 -223
  120. package/runtime/scripts/machine/home.mjs +0 -216
  121. package/runtime/scripts/machine/host-lock.mjs +0 -184
  122. package/runtime/scripts/machine/lock-recovery.mjs +0 -55
  123. package/runtime/scripts/machine/roles-contract.mjs +0 -90
  124. package/runtime/scripts/machine/runtime-change.mjs +0 -20
  125. package/runtime/scripts/machine/workflow-tree.mjs +0 -122
  126. package/runtime/scripts/reconciler/checklist-items.mjs +0 -7
  127. package/runtime/scripts/reconciler/guard-row.mjs +0 -25
  128. package/runtime/scripts/work/brand/brand-colour.mjs +0 -125
  129. package/runtime/scripts/work/brand/brand-tokens.mjs +0 -167
  130. package/runtime/scripts/work/brand/brand-value-source.mjs +0 -106
  131. package/runtime/scripts/work/brand/brand.mjs +0 -892
  132. package/runtime/scripts/work/draw/draw-layer.mjs +0 -306
  133. package/runtime/scripts/work/draw/draw-rationale-page.mjs +0 -237
  134. package/runtime/scripts/work/draw/draw-rationale-ref.mjs +0 -14
  135. package/runtime/scripts/work/draw/draw-rationale.mjs +0 -353
  136. package/runtime/scripts/work/draw/draw-source.mjs +0 -412
  137. package/runtime/scripts/work/draw/draw-taste.mjs +0 -278
  138. package/runtime/scripts/work/draw/draw-typecheck.mjs +0 -72
  139. package/runtime/scripts/work/draw/rationale-findings.mjs +0 -205
  140. package/runtime/scripts/work/draw-grammar.mjs +0 -175
  141. package/runtime/scripts/work/draw-render-page.mjs +0 -188
  142. package/runtime/scripts/work/draw-render.mjs +0 -626
  143. package/runtime/scripts/work/png.mjs +0 -237
  144. package/runtime/scripts/work/render-tools.mjs +0 -64
@@ -1,89 +0,0 @@
1
- // env-dump-verdict.mjs - the command guard's ENV_DUMP rule: a command that writes the WHOLE environment to output (`env`,
2
- // `printenv`, bare `set`, `export -p`, `declare -x|-p`, `Get-ChildItem env:`, [Environment]::GetEnvironmentVariables(),
3
- // `node -p process.env`, `python -c "print(os.environ)"`, `cmd /c set`). The environment carries every token and key the
4
- // session holds (the AWS, Azure and Anthropic values reached a transcript through `env | grep ... | sed`); a filter or a
5
- // redaction after the dump does not make it safe, because names outside the pattern leak. Reading ONE named variable
6
- // (`echo $PATH`, `printenv HOME`, `$env:PATH`, process.env.HOME), `env VAR=x cmd`, `env -u VAR cmd` and shell options
7
- // (`set -e`, `set -euo pipefail`) pass, and so does any text that only mentions these commands: the rule judges parsed
8
- // commands, so an echo, a commit message, a heredoc or a comment never reaches it.
9
- import { assignedCommand } from './assigned-command.mjs';
10
-
11
- const ENV_DRIVE = /^env:[\\/]?\*?$/i;
12
- const LISTERS = new Set(['get-childitem', 'gci', 'dir', 'ls', 'get-item', 'gi']);
13
- const GET_ENV = /^\[(?:system\.)?environment\]::getenvironmentvariables$/i;
14
- const NODE_PROGRAMS = new Set(['node', 'nodejs']);
15
- const PYTHON_PROGRAMS = new Set(['python', 'python3', 'python2', 'py']);
16
- // process.env as the whole argument of a printing or serialising call; or the whole value of `node -p`.
17
- const NODE_WHOLE = new RegExp([
18
- String.raw`(?:console\.\w+|JSON\.stringify|util\.inspect|\binspect|Object\.(?:keys|entries|values)|process\.std(?:out|err)\.write|require\(['"]fs['"]\)\.\w+|writeFileSync)`,
19
- String.raw`\s*\(\s*(?:[^()]*,\s*)?process\.env\s*[,)]`,
20
- ].join(''));
21
- const NODE_PRINT_WHOLE = new RegExp([String.raw`^\s*`, String.raw`process\.env`, String.raw`\s*;?\s*$`].join(''));
22
- const PYTHON_WHOLE = new RegExp([
23
- String.raw`(?:\bprint|pprint|json\.dumps|\bdict|\blist|\bsorted|\brepr|\bstr)`,
24
- String.raw`\s*\(\s*(?:[^()]*,\s*)?(?:os\.)?environ\s*[,)\]]|\benviron\.(?:items|keys|values|copy)\s*\(`,
25
- ].join(''));
26
- const PYTHON_OUTPUT = /\bprint\b|pprint|json\.dumps|stdout|\.write\(/;
27
-
28
- const refusal = (c, how) => ({ code: 'ENV_DUMP', command: [c.program, ...c.args].join(' ').slice(0, 200),
29
- reason: `${how} writes the whole environment to output, including every token and key the session holds; a grep or a redaction after it still leaks the names it does not match (the AWS, Azure and Anthropic values reached a transcript that way)`,
30
- remedy: 'read only the one named, non-secret variable you need (echo $NAME, $env:NAME, printenv NAME); never dump the environment' });
31
-
32
- const namesNothing = (args) => !args.some((a) => !a.startsWith('-'));
33
- const scriptAfter = (args, flag) => { const at = args.findIndex((a) => flag.test(a)); return at >= 0 ? args[at + 1] ?? '' : null; };
34
-
35
- // A relative path such as runtime/env is a file of that name, not the env program (an absolute path still is).
36
- const isRelativeProgramPath = (word) => /[\\/]/.test(String(word ?? '')) && !/^(?:[a-z]:)?[\\/]/i.test(String(word));
37
-
38
- // `declare`/`typeset` with no name lists every variable (no option, or -x / -p).
39
- const declareListsAll = (args) => {
40
- const options = args.filter((a) => /^-[a-zA-Z]+$/.test(a)).join('');
41
- return namesNothing(args) && (!options || /[xp]/.test(options));
42
- };
43
-
44
- const declareDump = (c) => (declareListsAll(c.args) ? refusal(c, `${c.program} with no name`) : null);
45
- const BUILTIN_DUMPS = new Map([
46
- ['set', (c) => (c.args.length === 0 && c.dialect !== 'powershell' ? refusal(c, 'set') : null)],
47
- ['export', (c) => (c.args.length === 0 || (c.args.length === 1 && c.args[0] === '-p') ? refusal(c, 'export -p') : null)],
48
- ['declare', declareDump],
49
- ['typeset', declareDump],
50
- ]);
51
-
52
- /** The shell-form dumpers (env/printenv/set/export/declare, PowerShell env: listers, GetEnvironmentVariables). */
53
- const shellDumpVerdict = (c) => {
54
- const { program, args } = c;
55
- if ((program === 'env' || program === 'printenv') && !isRelativeProgramPath(c.word)) return namesNothing(args) ? refusal(c, program) : null;
56
- const builtin = BUILTIN_DUMPS.get(program);
57
- if (builtin) return builtin(c);
58
- if (LISTERS.has(program) && args.some((a) => ENV_DRIVE.test(a))) return refusal(c, `${program} env:`);
59
- if (GET_ENV.test(program)) return refusal(c, '[Environment]::GetEnvironmentVariables()');
60
- return null;
61
- };
62
-
63
- /** `node -p|-e` whose script body prints or serializes process.env whole. */
64
- const nodeDumpHow = (args) => {
65
- const print = scriptAfter(args, /^(?:-p|--print)$/);
66
- const evalOnly = scriptAfter(args, /^(?:-e|--eval)$/);
67
- const body = print ?? evalOnly;
68
- return body != null && (NODE_WHOLE.test(body) || (print != null && NODE_PRINT_WHOLE.test(body))) ? 'a node script printing process.env' : null;
69
- };
70
-
71
- /** `python -c` whose script body prints os.environ. */
72
- const pythonDumpHow = (args) => {
73
- const body = scriptAfter(args, /^-c$/);
74
- return body != null && PYTHON_WHOLE.test(body) && PYTHON_OUTPUT.test(body) ? 'a python script printing os.environ' : null;
75
- };
76
-
77
- /** The ENV_DUMP refusal for one parsed command, or null. */
78
- const interpreterDumpVerdict = (c) => {
79
- if (NODE_PROGRAMS.has(c.program)) { const how = nodeDumpHow(c.args); if (how) return refusal(c, how); }
80
- if (PYTHON_PROGRAMS.has(c.program)) { const how = pythonDumpHow(c.args); if (how) return refusal(c, how); }
81
- return null;
82
- };
83
-
84
- export function envDumpVerdict(parsed) {
85
- const c = assignedCommand(parsed);
86
- const shell = shellDumpVerdict(c);
87
- if (shell) return shell;
88
- return interpreterDumpVerdict(c);
89
- }
@@ -1,27 +0,0 @@
1
- // guards-root.mjs - the one home of the op guard directory (jobs/, terminals/, seats/, refusals.jsonl).
2
- //
3
- // The runtime's own guards live in the host state root, never in a tracked path: <starciLocalRoot>/guards
4
- // (<runtime root>/.runtime/guards, or STARCI_LOCAL_ROOT/guards). STARCI_GUARDS_ROOT relocates THAT directory only - a
5
- // caller that names another root (a fixture's skill root) keeps <root>/runtime/guards. The node --test preload
6
- // (e2e/setup/isolated-temp.mjs) gives every spec process its own STARCI_GUARDS_ROOT, so two spec files that bind the
7
- // same fake Orca handle (fake-terminal-1) never read each other's binding, and no spec writes the live runtime's guards.
8
- import path from 'node:path';
9
- import { fileURLToPath } from 'node:url';
10
- import { starciLocalRoot } from '../../engine/db/machine.mjs';
11
- import { isSpecRun } from '../lib/env.mjs';
12
- import { tempRoot } from '../../engine/temp-root.mjs';
13
-
14
- export const GUARDS_ROOT_ENV = 'STARCI_GUARDS_ROOT';
15
- export const SKILL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
16
- const key = (p) => { const r = path.resolve(p); return process.platform === 'win32' ? r.toLowerCase() : r; };
17
-
18
- /**
19
- * The guard directory of `skillRoot`: the runtime's own lives in the host state root, a fixture root keeps its own. A spec
20
- * run with no STARCI_GUARDS_ROOT and no STARCI_LOCAL_ROOT uses an OS-temp directory, so a spec never writes the host's guards.
21
- */
22
- export function guardsRoot(skillRoot = SKILL_ROOT, env = process.env) {
23
- if (key(skillRoot) !== key(SKILL_ROOT)) return path.join(skillRoot, 'runtime', 'guards');
24
- if (env[GUARDS_ROOT_ENV]) return path.resolve(env[GUARDS_ROOT_ENV]);
25
- if (isSpecRun(env) && !env.STARCI_LOCAL_ROOT) return path.join(tempRoot({ env }), 'starci-test-guards');
26
- return path.join(starciLocalRoot(env), 'guards');
27
- }
@@ -1,343 +0,0 @@
1
- // install.mjs — puts the shared-checkout guard around an op or [Worker] agent.
2
- //
3
- // starci kernel dispatch (opGuardLaunch), the [Worker] launch (workers.mjs workerGuard) and the Kernel launch
4
- // (scripts/kernel/start-workflow.mjs, role 'kernel') call guardLaunch() for every agent they start
5
- // (modules/kernel/api.yaml conventions.sharedCheckout). Each layer is idempotent and best effort - a
6
- // guard that cannot be installed is reported on the dispatch receipt, never a reason to refuse the launch:
7
- // 1. <guards root>/jobs/<job>.json — the job's identity and owned paths as absolute paths. worker-start owns the
8
- // agent's environment, so the launch binds it to the agent's Orca terminal (bindGuardTerminal ->
9
- // <guards root>/terminals/<handle>.json, unbound when that terminal closes). The agent's host runs
10
- // scripts/guards/command-guard.mjs as a PreToolUse hook (registered by launch trust, scripts/agent/trust.mjs):
11
- // it finds the guard by ORCA_TERMINAL_HANDLE and refuses a shell command the policy forbids before it runs.
12
- // 2. the target repository's reference-transaction hook (git runs it for every ref update, whoever the caller is):
13
- // a protected branch only moves forward and is never deleted; an op (its Orca terminal bound to a guard) creates
14
- // no worktree and lands only its owned paths. (refs/stash stays writable: lint-staged's pre-commit backup stores
15
- // one; the command guard refuses a sweeping stash.)
16
- // 3. the target repository's pre-commit hook (ensureWorkHook): a commit that stages files under .starciwork/ or
17
- // .starcistacks/ is refused when a YAML does not parse, a record the commit touches fails its scoped strict
18
- // validation, or a non-.enc file carries a secret (scripts/work/validate/work-hygiene.mjs; e2e never runs there).
19
- // config.yaml `guards: {historyHook: false}` / `{workHook: false}` switches a hook layer off.
20
- import fs from 'node:fs';
21
- import path from 'node:path';
22
- import { guardsRoot } from './guards-root.mjs';
23
- import { fileURLToPath } from 'node:url';
24
- import { revParseQuery } from '../api/git/rev-parse-query.mjs';
25
- import { checkIgnore } from '../api/git/check-ignore.mjs';
26
- import { lsFiles } from '../api/git/ls-files.mjs';
27
- import { worktreeListQuery } from '../api/git/worktree-list-query.mjs';
28
- import { safeRemove } from '../api/fs/safe-remove.mjs';
29
- import { artifactHoldReason } from '../machine/artifact-hold.mjs';
30
- import { allocationMs } from '../../engine/config.mjs';
31
-
32
- const here = path.dirname(fileURLToPath(import.meta.url));
33
- export { guardsRoot };
34
- const HOOK_MARKER = 'starci-history-guard';
35
- export const HOOK_VERSION = 9;
36
- export const WORK_HOOK_MARKER = 'starci-work-guard';
37
- export const RUNTIME_GIT_HOOKS_MARKER = 'starci-git-hooks';
38
- export const WORK_HOOK_VERSION = 1;
39
-
40
- const normOwned = (p) => path.resolve(p).replaceAll('\\', '/');
41
- const safeName = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, '_');
42
- // A single-quoted shell word: ' wrapped as '\'' (close, escaped quote, reopen).
43
- const SQUOTE = String.raw`'\''`;
44
- export const shellQuote = (s) => `'${String(s).replaceAll('\\', '/').replaceAll("'", SQUOTE)}'`;
45
- const JOB_GUARD_TTL_MS = allocationMs('jobGuard.ttlMs');
46
- const terminalsDir = (skillRoot = path.resolve(here, '..', '..')) => path.join(guardsRoot(skillRoot), 'terminals');
47
-
48
- // tmp + rename: a reader never sees a torn guard file. Files of the directory older than JOB_GUARD_TTL_MS are pruned: a
49
- // guard outlives its worker only as history.
50
- function writeGuardFile(dir, name, body) {
51
- fs.mkdirSync(dir, { recursive: true });
52
- const file = path.join(dir, `${safeName(name)}.json`);
53
- const tmp = `${file}.${process.pid}.tmp`;
54
- fs.writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`);
55
- fs.renameSync(tmp, file);
56
- try {
57
- const cutoff = Date.now() - JOB_GUARD_TTL_MS;
58
- for (const other of fs.readdirSync(dir)) {
59
- const full = path.join(dir, other);
60
- if (full !== file && fs.statSync(full).mtimeMs < cutoff) fs.rmSync(full, { force: true });
61
- }
62
- } catch { /* pruning is housekeeping */ }
63
- return file;
64
- }
65
-
66
- /**
67
- * The roles a job guard is written for. 'op' is an op or [Worker] agent. 'kernel' is the Kernel: its shell commands meet
68
- * the same command guard (PreToolUse) as an op's, but the history hook does not apply its op rules to a kernel guard,
69
- * because the runtime's own git (checkpoints, land scratch trees) runs as children of the Kernel's api calls and
70
- * inherits its ORCA_TERMINAL_HANDLE (contract change kernel-guard-file). 'critic' is the Critic of a product: its `reach` names the
71
- * one directory it may read and the one file it may write (scripts/guards/critic-reach.mjs).
72
- */
73
- const GUARD_ROLES = Object.freeze(['op', 'kernel', 'critic']);
74
-
75
- /** <guards root>/jobs/<job>.json — who the worker is, its role and which absolute paths it owns. */
76
- export function writeJobGuard({ skillRoot = path.resolve(here, '..', '..'), jobId, workflowId, ledgerRepo, owned, workflowWorktree = null, role = 'op', op = null, reach = null }) {
77
- if (!GUARD_ROLES.includes(role)) throw new Error(`unknown guard role ${role}`);
78
- // op: the job's op id, for the rules one op family owes (a uat op never starts a test world, launch-verdict.mjs).
79
- return writeGuardFile(path.join(guardsRoot(skillRoot), 'jobs'), jobId, { schema: 'starci/op-guard@1', role, jobId, workflowId, op: op ?? null,
80
- ledgerRepo: ledgerRepo ? path.resolve(ledgerRepo) : null, owned: [...new Set((owned ?? []).filter(Boolean).map(normOwned))],
81
- // The workflow worktree the op works in (scripts/kernel/workflow-worktree.mjs), or null: the guard refuses git history
82
- // rewrites and ref changes inside it; scoped worker commits are adopted by the runtime at settle.
83
- workflowWorktree: workflowWorktree ? path.resolve(workflowWorktree) : null, ...(reach ? { reach: { dir: path.resolve(reach.dir), verdictFile: reach.verdictFile } } : {}), writtenAt: new Date().toISOString() });
84
- }
85
-
86
- /**
87
- * <guards root>/terminals/<handle>.json — the job guard of a managed op, keyed by the Orca terminal its agent runs
88
- * in. worker-start owns a managed agent's environment; Orca exports ORCA_TERMINAL_HANDLE into that terminal, and the
89
- * command guard (PreToolUse hook) and the history hook find the op's guard by it.
90
- */
91
- export function bindGuardTerminal({ skillRoot = path.resolve(here, '..', '..'), handle, jobFile }) {
92
- const guard = JSON.parse(fs.readFileSync(jobFile, 'utf8'));
93
- return writeGuardFile(terminalsDir(skillRoot), handle, { ...guard, terminal: handle, boundAt: new Date().toISOString() });
94
- }
95
-
96
- /**
97
- * <guards root>/seats/<handle>.json — the tools a seat's agent may not use, keyed by the Orca terminal it runs in.
98
- * worker-start takes no provider argv, so a seat's tool denial (the [Supervisor]'s Agent/Task, start-supervisor.mjs
99
- * SEAT_DENIED_TOOLS) is enforced by the project PreToolUse hook (.claude/settings.json -> scripts/guards/seat-tools.mjs),
100
- * which denies a tool only for the terminal bound here.
101
- */
102
- const seatsDir = (skillRoot = path.resolve(here, '..', '..')) => path.join(guardsRoot(skillRoot), 'seats');
103
- export function bindSeatGuard({ skillRoot = path.resolve(here, '..', '..'), handle, role, deniedTools }) {
104
- return writeGuardFile(seatsDir(skillRoot), handle, { schema: 'starci/seat-guard@1', role, terminal: handle,
105
- deniedTools: [...new Set((deniedTools ?? []).map(String))], boundAt: new Date().toISOString() });
106
- }
107
-
108
- /** Remove the guard bound to terminal `handle` once that terminal is closed; true when a file was removed. */
109
- export function unbindGuardTerminal({ skillRoot = path.resolve(here, '..', '..'), handle }) {
110
- if (!handle) return false;
111
- const file = path.join(terminalsDir(skillRoot), `${safeName(handle)}.json`);
112
- if (!fs.existsSync(file)) return false;
113
- fs.rmSync(file, { force: true });
114
- return true;
115
- }
116
-
117
- /** One git call (a scripts/api/git call file) in the repository at `cwd`. */
118
- const git = (call, cwd, args) => call(args, { dir: cwd, timeout: 20_000 });
119
-
120
- export function historyHookBody({ branches = [], root, nodePath = process.execPath, terminals = terminalsDir() }) {
121
- const protectedList = [...new Set(['main', 'master', ...branches.filter((b) => /^[A-Za-z0-9._/-]+$/.test(b))])].join(' ');
122
- const q = shellQuote;
123
- return `#!/bin/sh
124
- # ${HOOK_MARKER} v${HOOK_VERSION} — installed by the StarCi runtime (scripts/guards/hook-install.mjs); rewritten on every op dispatch.
125
- # The shared branch is append-only (modules/ops/_common.yaml "Evidence, completion and commits"):
126
- # a protected branch only moves forward and is never deleted. An op - named by the Orca terminal it runs in
127
- # (<guards root>/terminals/<handle>.json) - never creates a worktree, and the commits it lands carry only its
128
- # owned paths (starci guard verify-commit).
129
- if [ "$1" != "prepared" ]; then exit 0; fi
130
- PROTECTED=" ${protectedList} "
131
- guard=""
132
- if [ -n "\${ORCA_TERMINAL_HANDLE:-}" ]; then
133
- guard=${q(terminals)}/"$(printf '%s' "$ORCA_TERMINAL_HANDLE" | tr -c 'A-Za-z0-9._-' '_')".json
134
- fi
135
- [ -n "$guard" ] && [ -f "$guard" ] || guard=""
136
- # A kernel guard (role "kernel", writeJobGuard) is the command guard's alone: the runtime's own git under the Kernel's
137
- # api calls inherits its terminal, so the op rules below never read it.
138
- if [ -n "$guard" ] && grep -q '"role": "kernel"' "$guard"; then guard=""; fi
139
- status=0
140
- op_worktree_refused=0
141
- while read -r old new ref; do
142
- case "$ref" in
143
- HEAD)
144
- # An op worker never creates a git worktree. \`git worktree add\` writes the new
145
- # worktree's HEAD from the checkout it runs in, whose own HEAD is not locked: however the worker ran git, the
146
- # new worktree's first ref update is refused here.
147
- if [ -n "$guard" ] && [ "$op_worktree_refused" = 0 ]; then
148
- gd=$(git rev-parse --git-dir 2>/dev/null)
149
- fmt=$(git rev-parse --show-ref-format 2>/dev/null)
150
- # A git that predates --show-ref-format (before 2.46) echoes the flag back and has only the files backend.
151
- case "$fmt" in files|--show-ref-format) fmt=files ;; esac
152
- if [ -n "$gd" ] && [ "$fmt" = "files" ] && [ ! -e "$gd/HEAD.lock" ]; then
153
- echo "starci history guard: refused - an op worker never creates a git worktree; work in the checkout you were dispatched to (a private worktree with links into the live repository deleted live files)" >&2
154
- op_worktree_refused=1; status=1
155
- fi
156
- fi ;;
157
- refs/heads/*)
158
- b="\${ref#refs/heads/}"
159
- case "$PROTECTED" in *" $b "*) ;; *) continue ;; esac
160
- case "$new" in *[!0]*) ;; *)
161
- echo "starci history guard: refused deleting protected branch $b" >&2; status=1; continue ;; esac
162
- case "$old" in *[!0]*) ;; *) old=$(git rev-parse -q --verify "$ref^{commit}" 2>/dev/null) ;; esac
163
- [ -z "$old" ] && continue
164
- if ! git merge-base --is-ancestor "$old" "$new" 2>/dev/null; then
165
- # The runtime rewinds a workflow branch to its last checkpoint (preserveAndReset) naming the target in STARCI_BRANCH_REWIND; that is
166
- # allowed only while the commit it leaves is kept under refs/heads/preserved, so nothing is lost.
167
- if [ "\${STARCI_BRANCH_REWIND:-}" = "$new" ] && [ -n "$(git for-each-ref --contains "$old" --format=x refs/heads/preserved 2>/dev/null)" ]; then continue; fi
168
- echo "starci history guard: refused moving protected branch $b from $old to $new - not a fast-forward (reset/amend/rebase rewrite a branch other workflows commit on; undo a commit with git revert)" >&2
169
- status=1; continue
170
- fi
171
- if [ -n "$guard" ]; then
172
- STARCI_GUARD_FILE="$guard" STARCI_RUNTIME=${q(root)} ${q(nodePath)} ${q(path.join(root, 'packages', 'cli', 'bin', 'starci.mjs'))} guard verify-commit "$old" "$new" || status=1
173
- fi ;;
174
- esac
175
- done
176
- exit $status
177
- `;
178
- }
179
-
180
- // A relative core.hooksPath (husky's .husky/_) resolves per checkout: husky generates that directory, with
181
- // its own `.gitignore` of `*`, only where `npm install` ran, so a linked worktree has none. A hooks directory
182
- // that does not exist yet, holds no file and has nothing tracked is given husky's own self-ignoring layout;
183
- // anything else stays refused. True when the hook file may be written.
184
- const admitFreshHooksDir = (root, hooksDir, isIgnored) => {
185
- const relDir = path.relative(root, hooksDir).replaceAll('\\', '/');
186
- const absent = !fs.existsSync(hooksDir);
187
- const empty = absent || (fs.statSync(hooksDir).isDirectory() && fs.readdirSync(hooksDir).length === 0);
188
- const tracked = git(lsFiles, root, ['--', relDir]);
189
- if (!empty || tracked.status !== 0 || tracked.stdout.trim()) return false;
190
- fs.mkdirSync(hooksDir, { recursive: true });
191
- fs.writeFileSync(path.join(hooksDir, '.gitignore'), '*\n');
192
- if (isIgnored()) return true;
193
- if (absent) safeRemove(hooksDir, { hold: artifactHoldReason });
194
- else fs.rmSync(path.join(hooksDir, '.gitignore'), { force: true });
195
- return false;
196
- };
197
-
198
- /**
199
- * hookTarget(repoRoot, name) -> {root, hooksDir, file} | {installed: false, reason, path?}
200
- * Resolves where hook `name` goes: into the repository's effective hooks
201
- * directory (core.hooksPath, e.g. husky's .husky/_, else .git/hooks). A foreign
202
- * hook of that name is never overwritten; a hooks directory whose new file git
203
- * would offer for tracking is left alone (no foreign file in a product repo),
204
- * except an absent, untracked one (husky's .husky/_ in a linked worktree), which
205
- * gets husky's own self-ignoring `.gitignore` of `*`.
206
- */
207
- function hookTarget(repoRoot, name) {
208
- const top = git(revParseQuery, repoRoot, ['--show-toplevel']);
209
- if (top.status !== 0) return { installed: false, reason: 'not-a-git-checkout' };
210
- const root = path.resolve(top.stdout.trim());
211
- const hooks = git(revParseQuery, root, ['--git-path', 'hooks']);
212
- if (hooks.status !== 0) return { installed: false, reason: 'no-hooks-path' };
213
- const hooksDir = path.resolve(root, hooks.stdout.trim());
214
- const file = path.join(hooksDir, name);
215
- const inside = (parent, child) => { const rel = path.relative(parent, child); return !rel.startsWith('..') && !path.isAbsolute(rel); };
216
- const inWorktree = inside(root, hooksDir) && !inside(path.join(root, '.git'), hooksDir);
217
- if (inWorktree && !fs.existsSync(file)) {
218
- const rel = path.relative(root, file).replaceAll('\\', '/');
219
- const isIgnored = () => git(checkIgnore, root, ['-q', '--no-index', '--', rel]).status === 0;
220
- if (!isIgnored() && !admitFreshHooksDir(root, hooksDir, isIgnored)) return { installed: false, reason: 'hooks-dir-tracked', path: file };
221
- }
222
- return { root, hooksDir, file };
223
- }
224
-
225
- /**
226
- * ensureHistoryHook(repoRoot) -> {installed, path?, reason?}
227
- * Writes the reference-transaction hook into the repository's effective hooks directory (hookTarget).
228
- * A foreign hook of that name is never overwritten.
229
- */
230
- export function ensureHistoryHook(repoRoot, { skillRoot = path.resolve(here, '..', '..'), nodePath = process.execPath } = {}) {
231
- const target = hookTarget(repoRoot, 'reference-transaction');
232
- if (target.installed === false) return target;
233
- const { root, hooksDir, file } = target;
234
- const branches = [];
235
- const list = git(worktreeListQuery, root, ['--porcelain']);
236
- if (list.status === 0) for (const line of list.stdout.split(/\r?\n/)) if (line.startsWith('branch refs/heads/')) branches.push(line.slice('branch refs/heads/'.length));
237
- const body = historyHookBody({ branches, root: skillRoot, nodePath, terminals: terminalsDir(skillRoot) });
238
- if (fs.existsSync(file)) {
239
- const current = fs.readFileSync(file, 'utf8');
240
- if (!current.includes(HOOK_MARKER)) return { installed: false, reason: 'foreign-hook', path: file };
241
- if (current === body) return { installed: true, path: file, changed: false };
242
- }
243
- fs.mkdirSync(hooksDir, { recursive: true });
244
- const tmp = `${file}.${process.pid}.tmp`;
245
- fs.writeFileSync(tmp, body, { mode: 0o700 });
246
- fs.renameSync(tmp, file);
247
- try { fs.chmodSync(file, 0o700); } catch { /* windows */ }
248
- return { installed: true, path: file, changed: true };
249
- }
250
-
251
- /** The work-record check shared by the dispatch hook and the runtime's generated pre-commit hook. */
252
- export function workHookCheck({ root, nodePath = process.execPath }) {
253
- const q = shellQuote;
254
- return String.raw`# ${WORK_HOOK_MARKER} v${WORK_HOOK_VERSION} - installed by the StarCi runtime (scripts/guards/hook-install.mjs); rewritten on every op dispatch.
255
- # Staged Work and stack files are checked before the commit exists: YAML that parses, records that pass their scoped
256
- # strict validation, and no secret outside an .enc file (starci work hygiene). e2e never runs here.
257
- if git diff --cached --name-only --diff-filter=ACMR | grep -Eq '(^|/)\.(starciwork|starcistacks)/'; then
258
- STARCI_RUNTIME=${q(root)} ${q(nodePath)} ${q(path.join(root, 'packages', 'cli', 'bin', 'starci.mjs'))} work hygiene staged --repo "$(git rev-parse --show-toplevel)" || exit 1
259
- fi
260
- `;
261
- }
262
-
263
- /**
264
- * The pre-commit hook body. The check runs only when the index holds a file under .starciwork/ or .starcistacks/ (a
265
- * commit of anything else costs one git call). A husky dispatcher that shared the hook's place is chained after it.
266
- */
267
- function workHookBody({ root, nodePath = process.execPath }) {
268
- return `#!/bin/sh
269
- ${workHookCheck({ root, nodePath })}
270
- # husky's generated dispatcher (.husky/_/h) keeps running the repository's own pre-commit
271
- if [ -f "$(dirname "$0")/h" ]; then . "$(dirname "$0")/h"; fi
272
- `;
273
- }
274
- // husky 9 writes this two-line dispatcher into .husky/_/<hook>; it is generated output, so it may be wrapped.
275
- const HUSKY_DISPATCHER = /^#!\/usr\/bin\/env sh\s+\.\s+"\$\(dirname "\$0"\)\/h"\s*$/;
276
-
277
- /**
278
- * ensureWorkHook(repoRoot) -> {installed, path?, reason?, changed?}
279
- * Writes the pre-commit hook next to ensureHistoryHook's, by the same hookTarget rules. A foreign hook is never
280
- * overwritten, except husky's generated dispatcher, which the new hook wraps.
281
- */
282
- export function ensureWorkHook(repoRoot, { skillRoot = path.resolve(here, '..', '..'), nodePath = process.execPath } = {}) {
283
- const target = hookTarget(repoRoot, 'pre-commit');
284
- if (target.installed === false) return target;
285
- const { hooksDir, file } = target;
286
- const body = workHookBody({ root: skillRoot, nodePath });
287
- if (fs.existsSync(file)) {
288
- const current = fs.readFileSync(file, 'utf8');
289
- if (current.includes(RUNTIME_GIT_HOOKS_MARKER)) return { installed: true, path: file, changed: false };
290
- if (current.includes(WORK_HOOK_MARKER)) { if (current === body) return { installed: true, path: file, changed: false }; }
291
- else if (!HUSKY_DISPATCHER.test(current)) return { installed: false, reason: 'foreign-hook', path: file };
292
- }
293
- fs.mkdirSync(hooksDir, { recursive: true });
294
- const tmp = `${file}.${process.pid}.tmp`;
295
- fs.writeFileSync(tmp, body, { mode: 0o700 });
296
- fs.renameSync(tmp, file);
297
- try { fs.chmodSync(file, 0o700); } catch { /* windows */ }
298
- return { installed: true, path: file, changed: true };
299
- }
300
-
301
- const guardSettings = (config) => ({
302
- historyHook: config?.guards?.historyHook !== false,
303
- workHook: config?.guards?.workHook !== false,
304
- });
305
-
306
- /**
307
- * guardLaunch({jobId, workflowId, ledgerRepo, owned, repos, config, workflowWorktree, role, op}) -> {receipt}
308
- * receipt rides on the dispatch record. The caller binds receipt.jobFile to the agent's Orca terminal once
309
- * worker-start returns it (bindGuardTerminal), which is what the command guard and the history hook read.
310
- */
311
- export function guardLaunch({ skillRoot = path.resolve(here, '..', '..'), jobId, workflowId, ledgerRepo, owned = [], repos = [], config = null, workflowWorktree = null, role = 'op', op = null }) {
312
- const settings = guardSettings(config);
313
- const receipt = { jobFile: null, hooks: [] };
314
- try { receipt.jobFile = writeJobGuard({ skillRoot, jobId, workflowId, ledgerRepo, owned, workflowWorktree, role, op }); }
315
- catch (e) { receipt.jobFile = { error: String(e?.message ?? e) }; }
316
- const installPerRepo = (repos, install, sink) => {
317
- for (const repo of new Set(repos.filter(Boolean).map((r) => path.resolve(r)))) {
318
- try { sink.push({ repo, ...install(repo) }); }
319
- catch (e) { sink.push({ repo, installed: false, reason: String(e?.message ?? e) }); }
320
- }
321
- };
322
- if (settings.historyHook) installPerRepo(repos, (repo) => ensureHistoryHook(repo, { skillRoot }), receipt.hooks);
323
- else receipt.hooks = [{ disabled: true }];
324
- if (settings.workHook) { receipt.workHooks = []; installPerRepo(repos, (repo) => ensureWorkHook(repo, { skillRoot }), receipt.workHooks); }
325
- else receipt.workHooks = [{ disabled: true }];
326
- return { receipt };
327
- }
328
-
329
- /**
330
- * The layers a dispatch receipt (guardLaunch's, as starci kernel dispatch records it on op-dispatched `guard`) says did not
331
- * install: [] for a whole guard. A switched-off layer is not a failure.
332
- */
333
- export function guardReceiptErrors(receipt) {
334
- if (!receipt || typeof receipt !== 'object') return [];
335
- const out = [];
336
- const err = (layer, value) => { if (value) out.push(`${layer}: ${String(value).slice(0, 160)}`); };
337
- err('guard', receipt.error);
338
- err('jobFile', receipt.jobFile?.error);
339
- err('terminal', receipt.terminal?.error);
340
- for (const hook of Array.isArray(receipt.hooks) ? receipt.hooks : []) if (hook?.installed === false) err(`history hook ${hook.repo ?? ''}`.trim(), hook.reason ?? 'not installed');
341
- for (const hook of Array.isArray(receipt.workHooks) ? receipt.workHooks : []) if (hook?.installed === false) err(`work hook ${hook.repo ?? ''}`.trim(), hook.reason ?? 'not installed');
342
- return out;
343
- }
@@ -1,12 +0,0 @@
1
- import { hookErrorMessage } from './hook-io.mjs';
2
-
3
- /** One IPC request from the owning hook; the process ends after its result reaches that parent. */
4
- export function receiveVerdict(decide) {
5
- process.once('disconnect', () => process.exit(2));
6
- process.once('message', async ({ input, root, bindings }) => {
7
- let result;
8
- try { result = { ok: true, decision: await decide(input, { root, bindings }) }; }
9
- catch (error) { result = { ok: false, error: hookErrorMessage(error) }; }
10
- process.send(result, (error) => process.exit(error ? 2 : 0));
11
- });
12
- }
@@ -1,41 +0,0 @@
1
- // The refusals of install-family and Kernel-mailbox commands, split out of command-guard.mjs (which calls them).
2
- /** The install-through-link refusal of one npm/pnpm/yarn command (no guard needed), or null. `npm`: deps-guard.mjs. */
3
- export function installLinkVerdict({ program, args, cwd, npm }) {
4
- if (npm.classifyInstall(program, args).kind === 'pass') return null;
5
- const linked = npm.linkedNodeModulesOf(program, args, cwd);
6
- if (!linked) return null;
7
- return { tool: program, code: 'DEPS_THROUGH_LINK', command: `${program} ${args.join(' ')}`.slice(0, 200),
8
- reason: `${linked.nodeModules} is a link to ${linked.target ?? 'another tree'}: ${program} would empty that live node_modules`,
9
- remedy: 'node_modules here is a link to another checkout: unlink it first (cmd /c rmdir) and install for real' };
10
- }
11
-
12
- export async function installVerdict({ program, args, cwd, guard, deps }) {
13
- const { classifyInstall, peerLeasedJobs } = deps.npm;
14
- const kind = classifyInstall(program, args).kind;
15
- if (kind === 'pass') return null;
16
- const command = `${program} ${args.join(' ')}`.slice(0, 200);
17
- const linked = installLinkVerdict({ program, args, cwd, npm: deps.npm });
18
- if (linked) return linked;
19
- if (kind !== 'clean-install' || !guard?.ledgerRepo) return null;
20
- let peers;
21
- try { peers = await peerLeasedJobs({ ledgerRepo: guard.ledgerRepo, workflowId: guard.workflowId }); }
22
- catch (e) { deps.say(`starci guard: could not read peer leases (${e?.message ?? e})`); return null; }
23
- if (!peers.jobs.length) return null;
24
- const names = peers.jobs.slice(0, 5).map((j) => `${j.jobId} (${j.workflowId})`).join(', ');
25
- return { tool: 'npm', code: 'DEPS_DELETE_WHILE_PEER_LEASED', command,
26
- reason: `${command} deletes node_modules while other workflows' jobs run checks from it: ${names}`,
27
- remedy: 'use `npm install`, or report blocked environment naming the missing dependency' };
28
- }
29
-
30
- // The Kernel never reads Orca's mailbox itself: `orchestration check --ack` consumes deliveries before the ledger records
31
- // them, so it reads through `starci kernel messages` / `starci kernel questions` and the runtime's api drains (lane MAILC). Ops and
32
- // [Worker]s keep `check`: Orca's worker protocol has them read their own Run's deliveries (modules/host/orca/api.yaml
33
- // operationAgent), and no ledger record depends on those.
34
- export function kernelMailboxVerdict(program, args, guard) {
35
- if (program !== 'orca' || guard?.role !== 'kernel') return null;
36
- const words = args.filter((a) => !a.startsWith('-'));
37
- if (words[0] !== 'orchestration' || words[1] !== 'check') return null;
38
- return { code: 'KERNEL_ORCA_CHECK', command: ['orca', ...args].join(' ').slice(0, 200),
39
- reason: 'the Kernel never runs orca orchestration check: its --ack consumes deliveries before the ledger records them',
40
- remedy: 'read messages with `starci kernel messages --repo <repo> --workflow <id>` and questions with `starci kernel questions`; the runtime drains the mailbox' };
41
- }
@@ -1,63 +0,0 @@
1
- // kernel-seat.mjs — what a Kernel seat (a bound guard of role kernel) may run, from the `kernel` table of
2
- // modules/kernel/command-policy.yaml. The PreToolUse guard and the PATH shims evaluate it (command-policy.mjs policyVerdict): a
3
- // mutating verb is the runtime's, never the seat's. Refusals name the status and decision verbs without running them.
4
- import path from 'node:path';
5
- import { plainWords } from '../lib/menu-parts.mjs';
6
- import { redirectTargetsOf, refusal } from './rights.mjs';
7
- import { kernelMailboxVerdict } from './install-verdict.mjs';
8
- import { orcaSelfLifecycleAllowed } from './orca-self-lifecycle.mjs';
9
-
10
- const OPTION = /^--?([A-Za-z][\w-]*)(?:=.*)?$/;
11
- const flagsOf = (args) => new Set(args.map((value) => OPTION.exec(String(value))?.[1]).filter(Boolean));
12
-
13
- /** The first two non-option words of a starci call: its group and verb. */
14
- /** Whether the Kernel seat may run `starci <group> <verb>` with these option names: {allowed, reason?}. */
15
- function kernelSeatAllowsVerb(table, group, verb, flags = new Set()) {
16
- if (!(table?.verbs?.[group] ?? []).includes(verb)) return { allowed: false, reason: `starci ${group} ${verb} is not a verb of the Kernel seat` };
17
- const blocked = (table['read-flags']?.[verb] ?? []).find((flag) => flags.has(flag));
18
- return blocked ? { allowed: false, reason: `starci ${group} ${verb} --${blocked} mutates the workflow` } : { allowed: true };
19
- }
20
-
21
- /**
22
- * The refusal of a call that redirects output into a file, or null. The Kernel writes no file: a redirection (> file, 2>file, >> file) leaves a stray in the workflow
23
- * tree it works in, which a later fail decision of that tree then has to recover from (live: a file named 0, left by a redirect, made every settle-fail of a job unrecoverable).
24
- * The null devices are not files; the status text is read with --field, never redirected.
25
- */
26
- function kernelRedirectVerdict({ command, cwd }) {
27
- const targets = redirectTargetsOf(command, cwd);
28
- if (!targets.length) return null;
29
- const use = 'read with starci kernel status --field <name> or starci kernel logs; the call needs no file';
30
- return refusal('KERNEL_NO_FILE_WRITE', command, `the Kernel seat writes no file: the redirection into ${path.basename(targets[0])} would leave a stray file in its workflow tree`, use, use);
31
- }
32
-
33
- /** The Kernel-seat verdicts of one shell call as a tool verdict, or null for any other role: today the no-file-write rule. */
34
- export const kernelSeatCallVerdict = ({ role, command, cwd }) => {
35
- const v = role === 'lead' ? kernelRedirectVerdict({ command, cwd }) : null;
36
- return v ? { tool: 'shell', ...v } : null;
37
- };
38
-
39
- const useOf = (table) => table.use ?? 'starci kernel decide';
40
-
41
- /** The refusal of a call a Kernel seat may not make, or null when the seat may run it. Programs other than starci are refused unless pure reads. */
42
- export function kernelSeatVerdict({ policy, program, args, text, guard = null, handle = null }) {
43
- const table = policy?.kernel;
44
- if (!table) return null;
45
- // The seat is an Orca worker: its own heartbeat, ask and worker_done stay, and its mailbox stays the runtime's.
46
- if (program === 'orca') {
47
- const mailbox = kernelMailboxVerdict('orca', args, guard);
48
- if (mailbox) return mailbox;
49
- if (orcaSelfLifecycleAllowed({ role: 'lead', args, handle, policy })) return null;
50
- }
51
- if (program === 'starci') {
52
- const [group, verb] = plainWords(args, 2);
53
- const verdict = kernelSeatAllowsVerb(table, group, verb, flagsOf(args));
54
- if (verdict.allowed) return null;
55
- const use = useOf(table);
56
- return refusal('KERNEL_USE_DECIDE', text, `${verdict.reason}: the runtime runs it, and the Kernel answers the items of its menu`, use, use);
57
- }
58
- const reads = new Set((policy.read ?? []).map(String));
59
- const denied = new Set((table['deny-programs'] ?? []).map(String));
60
- if (reads.has(program) && !denied.has(program)) return null;
61
- const use = useOf(table);
62
- return refusal('KERNEL_STARCI_ONLY', text, `the Kernel seat runs starci verbs and pure reads: ${program} is neither`, use, use);
63
- }