@alecsibilia/luca 14.0.0-alpha.2 → 14.0.0-alpha.21
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 +76 -0
- package/README.md +7 -2
- package/board/client/board-look.ts +31 -1
- package/board/client/board-panel-parts.tsx +150 -0
- package/board/client/board-panel-stages.ts +99 -0
- package/board/client/board-panel-styles.ts +223 -0
- package/board/client/board-panel.tsx +77 -1275
- package/board/client/engine-life.ts +48 -0
- package/board/client/final-review-stack.tsx +144 -0
- package/board/client/heartbeat.tsx +61 -0
- package/board/client/helper-lines.tsx +69 -0
- package/board/client/live-feed.tsx +86 -0
- package/board/client/live-line.ts +73 -0
- package/board/client/model-line.tsx +178 -0
- package/board/client/needs-you-section.tsx +133 -0
- package/board/client/one-line.tsx +193 -0
- package/board/client/repo-wait-banner.tsx +69 -0
- package/board/client/run-footer.tsx +68 -0
- package/board/client/run-header.tsx +189 -0
- package/board/client/run-picker.tsx +98 -0
- package/board/client/status-bar.tsx +131 -0
- package/board/client/step-dots.tsx +127 -0
- package/board/client/stuck-actions.tsx +269 -0
- package/board/client/ticket-card-view.tsx +238 -0
- package/board/client/ticket-detail.tsx +87 -0
- package/board/client/ticket-stack.tsx +119 -0
- package/board/client/ticket-wait.ts +188 -0
- package/board/client/timeline-rows.tsx +41 -14
- package/board/client/usage-line.tsx +51 -0
- package/board/client/wait-line.tsx +39 -0
- package/board/index.client.tsx +1 -1
- package/board/index.server.ts +7 -1
- package/board/package.json +1 -1
- package/board/server/board-panel-state-helpers.ts +67 -0
- package/board/server/board-server-check.ts +294 -0
- package/board/server/board-server-engine.ts +129 -0
- package/board/server/board-server-events.ts +134 -0
- package/board/server/board-server-read.ts +190 -0
- package/board/server/board-server-resume.ts +164 -0
- package/board/server/board-server-start.ts +174 -0
- package/board/server/board-server-store.ts +281 -0
- package/board/server/board-server-types.ts +114 -0
- package/board/server/board-server-util.ts +4 -0
- package/board/server/board-server.ts +52 -943
- package/board/server/board-vocabulary-engine.ts +144 -0
- package/board/server/board-vocabulary-review.ts +115 -0
- package/board/server/board-vocabulary-run.ts +145 -0
- package/board/server/board-vocabulary-shapes.ts +153 -0
- package/board/server/board-vocabulary.ts +20 -375
- package/board/server/current-step-panel-helpers.ts +108 -0
- package/board/server/describe-record.ts +403 -0
- package/board/server/engine-launch.ts +20 -3
- package/board/server/engine-restart-helpers.ts +105 -0
- package/board/server/engine-watch.ts +45 -5
- package/board/server/journal-rebuild-helpers.ts +169 -0
- package/board/server/live-file.ts +130 -0
- package/board/server/luca-version.ts +1 -1
- package/board/server/make-rows.ts +14 -629
- package/board/server/record-texts.ts +350 -0
- package/board/server/reduce-board-agent-start.ts +224 -0
- package/board/server/reduce-board-agents.ts +160 -0
- package/board/server/reduce-board-counters.ts +138 -0
- package/board/server/reduce-board-create.ts +148 -0
- package/board/server/reduce-board-final-review.ts +174 -0
- package/board/server/reduce-board-needs-you.ts +283 -0
- package/board/server/reduce-board-reasons.ts +59 -0
- package/board/server/reduce-board-run.ts +255 -0
- package/board/server/reduce-board-settle.ts +88 -0
- package/board/server/reduce-board-steps.ts +226 -0
- package/board/server/reduce-board-text.ts +139 -0
- package/board/server/reduce-board-ticket-checks.ts +168 -0
- package/board/server/reduce-board-ticket-join.ts +144 -0
- package/board/server/reduce-board-ticket-start.ts +132 -0
- package/board/server/reduce-board-update.ts +117 -0
- package/board/server/reduce-board-usage.ts +247 -0
- package/board/server/reduce-board.ts +100 -1917
- package/board/server/reply-post.ts +407 -0
- package/board/server/run-registry.ts +8 -4
- package/board/server/ticket-stages-panel-helpers.ts +296 -0
- package/board/shared/board-rows.ts +9 -0
- package/board/shared/board-rpc.ts +42 -0
- package/board/shared/board-state-counts.ts +77 -0
- package/board/shared/board-state-live.ts +99 -0
- package/board/shared/board-state-review.ts +59 -0
- package/board/shared/board-state-run.ts +236 -0
- package/board/shared/board-state-ticket.ts +257 -0
- package/board/shared/board-state-usage.ts +107 -0
- package/board/shared/board-state.ts +97 -567
- package/board/shared/reply-actions.ts +207 -0
- package/engine/agents/agent-launcher.ts +27 -4
- package/engine/agents/claude-launcher-helpers.ts +233 -0
- package/engine/agents/claude-launcher.ts +28 -669
- package/engine/agents/claude-messages.ts +212 -0
- package/engine/agents/claude-options.ts +43 -8
- package/engine/agents/common-instructions.ts +138 -0
- package/engine/agents/input-channel.ts +36 -0
- package/engine/agents/launch-paths.ts +29 -0
- package/engine/agents/launch-session.ts +227 -0
- package/engine/agents/learner-instructions.ts +31 -0
- package/engine/agents/open-session.ts +210 -0
- package/engine/agents/prompt-sections.ts +95 -0
- package/engine/agents/rejoin-prompts.ts +135 -0
- package/engine/agents/reviewer-instructions.ts +99 -0
- package/engine/agents/role-instructions.ts +31 -244
- package/engine/agents/role-prompts.ts +39 -109
- package/engine/agents/role-results.ts +106 -7
- package/engine/agents/scripted-launcher.ts +21 -6
- package/engine/agents/session-pool.ts +124 -0
- package/engine/agents/turn-progress.ts +283 -0
- package/engine/agents/writer-instructions.ts +151 -0
- package/engine/cli/computer-adapters-real.ts +15 -3
- package/engine/cli/computer-adapters.ts +2 -0
- package/engine/cli/computer-checks.ts +87 -4
- package/engine/cli/config-reload-text.ts +39 -0
- package/engine/cli/decision-model-setup.ts +70 -0
- package/engine/cli/doctor-command.ts +1 -0
- package/engine/cli/doctor.ts +21 -3
- package/engine/cli/going-runs.ts +192 -36
- package/engine/cli/init-command.ts +1 -0
- package/engine/cli/init.ts +23 -1
- package/engine/cli/live-runs.ts +73 -0
- package/engine/cli/luca-run.ts +42 -7
- package/engine/cli/luca-skills.ts +183 -0
- package/engine/cli/luca.ts +8 -1
- package/engine/cli/models-command.ts +119 -0
- package/engine/cli/read-run.ts +81 -0
- package/engine/cli/run-args.ts +33 -6
- package/engine/cli/run-demo.ts +143 -0
- package/engine/cli/run-end.ts +184 -0
- package/engine/cli/run-modes-helpers.ts +81 -0
- package/engine/cli/run-modes.ts +62 -439
- package/engine/cli/setup.ts +5 -48
- package/engine/cli/unfinished-runs.ts +122 -0
- package/engine/cli/upgrade-command.ts +212 -30
- package/engine/cli/upgrade-finish.ts +149 -0
- package/engine/cli/upgrade.ts +112 -71
- package/engine/config/config-fields.ts +127 -0
- package/engine/config/config-reload.ts +250 -0
- package/engine/config/engine-config-schema.ts +235 -0
- package/engine/config/engine-config.ts +72 -142
- package/engine/config/test-commands.ts +102 -0
- package/engine/core/agent-guards-helpers.ts +84 -0
- package/engine/core/agent-turn.ts +395 -0
- package/engine/core/already-done.ts +44 -1
- package/engine/core/build-action.ts +214 -0
- package/engine/core/build-context.ts +135 -0
- package/engine/core/build-launch.ts +259 -0
- package/engine/core/commit-in.ts +282 -0
- package/engine/core/decide-build-early.ts +108 -0
- package/engine/core/decide-build-ending.ts +145 -0
- package/engine/core/decide-build-helpers.ts +87 -0
- package/engine/core/decide-build-tickets.ts +207 -0
- package/engine/core/decide-build-view.ts +103 -0
- package/engine/core/decide-build.ts +101 -1368
- package/engine/core/decide-final-fix.ts +203 -0
- package/engine/core/decide-final-review.ts +55 -310
- package/engine/core/decide-red-green.ts +398 -0
- package/engine/core/decide-rejoin.ts +259 -0
- package/engine/core/decide-repo-wait.ts +129 -0
- package/engine/core/decide-run-budget.ts +86 -12
- package/engine/core/decide-stuck-helpers.ts +127 -0
- package/engine/core/decide-stuck.ts +60 -8
- package/engine/core/decide-ticket-review.ts +95 -0
- package/engine/core/decide-ticket.ts +267 -0
- package/engine/core/decide-waits.ts +47 -0
- package/engine/core/decide.ts +74 -23
- package/engine/core/engine-chores.ts +163 -0
- package/engine/core/environment-rerun.ts +83 -0
- package/engine/core/evidence-text.ts +84 -0
- package/engine/core/execute-action.ts +303 -0
- package/engine/core/execute-already-done-check.ts +133 -0
- package/engine/core/execute-build.ts +68 -944
- package/engine/core/execute-changeset.ts +5 -7
- package/engine/core/execute-final-review.ts +125 -13
- package/engine/core/execute-gates.ts +182 -0
- package/engine/core/execute-intake.ts +130 -0
- package/engine/core/execute-join.ts +276 -0
- package/engine/core/execute-memory.ts +12 -0
- package/engine/core/execute-pull-request.ts +84 -0
- package/engine/core/execute-red-check.ts +219 -0
- package/engine/core/execute-repo-wait.ts +129 -0
- package/engine/core/execute-stuck.ts +25 -10
- package/engine/core/execute-waits.ts +158 -0
- package/engine/core/execute-with-jev.ts +118 -0
- package/engine/core/execute.ts +124 -831
- package/engine/core/final-failure-cap.ts +38 -0
- package/engine/core/final-review-action.ts +125 -0
- package/engine/core/final-review-text.ts +63 -17
- package/engine/core/final-stuck-text.ts +86 -0
- package/engine/core/fix-loop-text.ts +49 -2
- package/engine/core/left-out.ts +120 -0
- package/engine/core/live-file.ts +372 -0
- package/engine/core/loop-caps.ts +25 -2
- package/engine/core/not-in-run.ts +7 -3
- package/engine/core/pull-request-text.ts +255 -6
- package/engine/core/repo-wait-text.ts +81 -0
- package/engine/core/review-text.ts +55 -1
- package/engine/core/run-crash-recovery-helpers.ts +16 -0
- package/engine/core/run-session-close-helpers.ts +96 -0
- package/engine/core/run-start.ts +148 -0
- package/engine/core/run-stuck-text.ts +86 -0
- package/engine/core/schedule-rules.ts +193 -0
- package/engine/core/stuck-detail-text.ts +89 -0
- package/engine/core/stuck-format.ts +22 -0
- package/engine/core/stuck-text.ts +29 -332
- package/engine/core/ticket-stuck-text.ts +192 -0
- package/engine/core/worktree-changes.ts +106 -0
- package/engine/gates/already-done-check.ts +120 -0
- package/engine/gates/feature-map.ts +96 -0
- package/engine/gates/gate-runner.ts +108 -108
- package/engine/gates/gate-schemas.ts +61 -2
- package/engine/gates/leftover-scan.ts +154 -31
- package/engine/gates/new-tests.ts +45 -0
- package/engine/gates/own-files-check.ts +98 -0
- package/engine/gates/prepare-check.ts +60 -0
- package/engine/gates/prepare-slots.ts +99 -0
- package/engine/gates/red-check.ts +184 -16
- package/engine/gates/shell-check.ts +64 -0
- package/engine/gates/slot-waits.ts +55 -0
- package/engine/gates/slow-test-runner.ts +59 -0
- package/engine/gates/test-checks.ts +161 -0
- package/engine/gates/test-runner.ts +164 -5
- package/engine/gates/test-slots.ts +48 -0
- package/engine/git/git-adapter.ts +55 -396
- package/engine/git/git-commits.ts +132 -0
- package/engine/git/git-join.ts +139 -0
- package/engine/git/git-read.ts +123 -0
- package/engine/git/git-run.ts +32 -0
- package/engine/git/git-worktrees.ts +143 -0
- package/engine/git/outside-links.ts +55 -0
- package/engine/guards/after-turn-check.ts +31 -5
- package/engine/guards/guard-hook.ts +60 -12
- package/engine/guards/guard-roles.ts +68 -0
- package/engine/guards/helper-rules.ts +92 -0
- package/engine/guards/permission-rules.ts +161 -0
- package/engine/guards/role-rules-helpers.ts +48 -0
- package/engine/guards/role-rules.ts +34 -606
- package/engine/guards/shell-rules.ts +344 -0
- package/engine/guards/worktree-git.ts +208 -0
- package/engine/guards/worktree-snapshot.ts +412 -0
- package/engine/guards/worktree-state.ts +28 -825
- package/engine/guards/worktree-undo.ts +260 -0
- package/engine/guards/write-rules.ts +149 -0
- package/engine/index.ts +30 -9
- package/engine/intake/criteria-checks.ts +114 -0
- package/engine/intake/intake-checks.ts +164 -30
- package/engine/intake/intake-schemas.ts +37 -2
- package/engine/jev/clef-client.ts +259 -0
- package/engine/jev/decision-model-credentials.ts +212 -0
- package/engine/jev/jev-jobs.ts +144 -60
- package/engine/jev/jev-schemas.ts +86 -5
- package/engine/jev/jev-shadow-helpers.ts +218 -0
- package/engine/jev/jev-shadow.ts +109 -64
- package/engine/jev/model-pick.ts +275 -0
- package/engine/journal/agent-entries.ts +167 -0
- package/engine/journal/agent-message-entries.ts +72 -0
- package/engine/journal/agent-turn-replay.ts +203 -0
- package/engine/journal/already-done-entries.ts +55 -0
- package/engine/journal/commit-entries.ts +56 -0
- package/engine/journal/cross-ticket-replay.ts +222 -0
- package/engine/journal/decision-model-entries.ts +86 -0
- package/engine/journal/entry-fields.ts +10 -0
- package/engine/journal/final-review-entries.ts +128 -0
- package/engine/journal/final-review-replay.ts +324 -0
- package/engine/journal/final-review-round.ts +320 -0
- package/engine/journal/final-review-state.ts +180 -0
- package/engine/journal/gate-entries.ts +81 -0
- package/engine/journal/intake-entries.ts +87 -0
- package/engine/journal/join-entries.ts +126 -0
- package/engine/journal/journal-entry-schema.ts +218 -0
- package/engine/journal/journal-kinds.ts +97 -0
- package/engine/journal/journal-record-schema.ts +218 -0
- package/engine/journal/journal-record.ts +43 -1400
- package/engine/journal/journal.ts +6 -1
- package/engine/journal/limit-entries.ts +90 -0
- package/engine/journal/memory-entries.ts +124 -0
- package/engine/journal/memory-replay.ts +157 -0
- package/engine/journal/plan-replay.ts +139 -0
- package/engine/journal/pull-request-entries.ts +41 -0
- package/engine/journal/replay.ts +157 -2144
- package/engine/journal/reply-replay.ts +144 -0
- package/engine/journal/repo-wait-entries.ts +53 -0
- package/engine/journal/run-entries.ts +172 -0
- package/engine/journal/run-state.ts +264 -0
- package/engine/journal/shared-failure-replay.ts +133 -0
- package/engine/journal/stuck-entries.ts +193 -0
- package/engine/journal/ticket-progress.ts +392 -0
- package/engine/journal/ticket-replay.ts +411 -0
- package/engine/journal/ticket-result-replay.ts +364 -0
- package/engine/journal/ticket-retry-replay.ts +142 -0
- package/engine/journal/worktree-entries.ts +122 -0
- package/engine/models/model-score.ts +512 -0
- package/engine/shell/run-command.ts +187 -18
- package/engine/testing/agent-turn-fixtures.ts +287 -0
- package/engine/testing/already-done-fixtures.ts +97 -0
- package/engine/testing/build-fixtures.ts +68 -973
- package/engine/testing/checkouts-fixtures.ts +48 -0
- package/engine/testing/final-review-fixtures.ts +62 -1
- package/engine/testing/final-review-steps.ts +143 -0
- package/engine/testing/limit-fixtures.ts +85 -0
- package/engine/testing/live-file-fixtures.ts +57 -0
- package/engine/testing/practice-repo.ts +10 -4
- package/engine/testing/practice-run.ts +1 -1
- package/engine/testing/reply-fixtures.ts +88 -0
- package/engine/testing/run-budget-fixtures.ts +165 -0
- package/engine/testing/run-fixtures.ts +219 -0
- package/engine/testing/sdk-play.ts +177 -0
- package/engine/testing/setup-repo.ts +194 -0
- package/engine/testing/step-record-fixtures.ts +55 -0
- package/engine/testing/ticket-step-fixtures.ts +272 -0
- package/engine/testing/wait-scenarios.ts +186 -0
- package/engine/tracker/gh-retry.ts +217 -0
- package/engine/tracker/github-tracker.ts +310 -90
- package/engine/tracker/in-memory-tracker.ts +8 -4
- package/engine/tracker/tracker.ts +131 -6
- package/package.json +2 -1
- package/skills/luca-retro/SKILL.md +74 -0
- package/skills/luca-retro/scripts/retro-summary.ts +1356 -0
- package/skills/luca-unstick/SKILL.md +153 -0
- package/skills/luca-unstick/scripts/stuck-summary.ts +1063 -0
- package/engine/jev/jev-client.ts +0 -173
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure: which shell commands each role may run, checked one plain command at
|
|
3
|
+
* a time.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
ALLOW,
|
|
7
|
+
deny,
|
|
8
|
+
isWriter,
|
|
9
|
+
type GuardArgs,
|
|
10
|
+
type GuardRole,
|
|
11
|
+
type ToolDecision,
|
|
12
|
+
} from './guard-roles'
|
|
13
|
+
import { insideWorktree, mayWrite } from './write-rules'
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
slowTestCommands,
|
|
17
|
+
testCommands,
|
|
18
|
+
type EngineConfig,
|
|
19
|
+
type TestCommand,
|
|
20
|
+
} from '../config/engine-config'
|
|
21
|
+
|
|
22
|
+
/** One word of a shell command, and whether the shell would expand it. */
|
|
23
|
+
type Word = { text: string; expands: boolean }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Splits a command the way `sh` would, for the plain commands the guard
|
|
27
|
+
* allows. Fails on anything that chains, pipes, redirects, substitutes, or
|
|
28
|
+
* runs in the background, so one call is one command.
|
|
29
|
+
*/
|
|
30
|
+
const splitCommand = (
|
|
31
|
+
command: string
|
|
32
|
+
): { ok: true; words: Word[] } | { ok: false; error: string } => {
|
|
33
|
+
const words: Word[] = []
|
|
34
|
+
let text = ''
|
|
35
|
+
let expands = false
|
|
36
|
+
let inWord = false
|
|
37
|
+
let quote: "'" | '"' | null = null
|
|
38
|
+
const bad = (char: string) => ({
|
|
39
|
+
ok: false as const,
|
|
40
|
+
error: `"${char}" is not allowed: run one plain command per shell call, with no chains, pipes, redirects, or subshells.`,
|
|
41
|
+
})
|
|
42
|
+
for (const char of command) {
|
|
43
|
+
if (quote === "'") {
|
|
44
|
+
if (char === "'") quote = null
|
|
45
|
+
else text += char
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (quote === '"') {
|
|
49
|
+
if (char === '"') quote = null
|
|
50
|
+
else if ('$`\\'.includes(char)) return bad(char)
|
|
51
|
+
else text += char
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (char === "'" || char === '"') {
|
|
55
|
+
quote = char
|
|
56
|
+
inWord = true
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (char === ' ' || char === '\t') {
|
|
60
|
+
if (inWord) words.push({ text, expands })
|
|
61
|
+
text = ''
|
|
62
|
+
expands = false
|
|
63
|
+
inWord = false
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (';&|<>`$(){}\\!#\n\r'.includes(char)) return bad(char)
|
|
67
|
+
if ('*?[]~'.includes(char)) expands = true
|
|
68
|
+
text += char
|
|
69
|
+
inWord = true
|
|
70
|
+
}
|
|
71
|
+
if (quote !== null) return { ok: false, error: 'An unclosed quote.' }
|
|
72
|
+
if (inWord) words.push({ text, expands })
|
|
73
|
+
return { ok: true, words }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const wordsOf = (command: string): string[] | null => {
|
|
77
|
+
const split = splitCommand(command)
|
|
78
|
+
return split.ok ? split.words.map(({ text }) => text) : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const startsWith = ({
|
|
82
|
+
words,
|
|
83
|
+
prefix,
|
|
84
|
+
}: {
|
|
85
|
+
words: string[]
|
|
86
|
+
prefix: string[]
|
|
87
|
+
}): boolean => prefix.every((word, index) => words[index] === word)
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The check commands from the config a role may run: each test command,
|
|
91
|
+
* each slow test command (#563), types, lint. A `bun` test command may take extra arguments; the rest run
|
|
92
|
+
* only as written.
|
|
93
|
+
*/
|
|
94
|
+
export const checkCommands = ({
|
|
95
|
+
role,
|
|
96
|
+
config,
|
|
97
|
+
}: {
|
|
98
|
+
role: GuardRole
|
|
99
|
+
config: EngineConfig
|
|
100
|
+
}): { tests: TestCommand[]; others: string[] } => {
|
|
101
|
+
if (!isWriter(role)) return { tests: [], others: [] }
|
|
102
|
+
const { types, lint } = config.checks
|
|
103
|
+
return {
|
|
104
|
+
tests: [...testCommands({ config }), ...slowTestCommands({ config })],
|
|
105
|
+
others: [types, lint].filter(
|
|
106
|
+
(command): command is string => command !== undefined
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The configured commands that run only as written, tests included. */
|
|
112
|
+
const exactCommands = ({
|
|
113
|
+
tests,
|
|
114
|
+
others,
|
|
115
|
+
}: {
|
|
116
|
+
tests: TestCommand[]
|
|
117
|
+
others: string[]
|
|
118
|
+
}): string[] => [
|
|
119
|
+
...tests.filter(({ results }) => results !== 'bun').map(({ run }) => run),
|
|
120
|
+
...others,
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
export const GIT_READ_SUBCOMMANDS = [
|
|
124
|
+
'status',
|
|
125
|
+
'diff',
|
|
126
|
+
'log',
|
|
127
|
+
'show',
|
|
128
|
+
'ls-files',
|
|
129
|
+
'blame',
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
const FIND_WRITES = [
|
|
133
|
+
'-exec',
|
|
134
|
+
'-execdir',
|
|
135
|
+
'-ok',
|
|
136
|
+
'-okdir',
|
|
137
|
+
'-delete',
|
|
138
|
+
'-fprint',
|
|
139
|
+
'-fprint0',
|
|
140
|
+
'-fprintf',
|
|
141
|
+
'-fls',
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
/** `sed -n` with a line-range print script only: `5p`, `1,20p`, `$p`. */
|
|
145
|
+
const SED_PRINT = /^(\d+|\$)(,(\d+|\$))?p$/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The read-only commands every role with a shell may run, each a check of
|
|
149
|
+
* its words (the command name is `words[0]`).
|
|
150
|
+
*/
|
|
151
|
+
const READ_ONLY: Record<string, (words: string[]) => boolean> = {
|
|
152
|
+
ls: () => true,
|
|
153
|
+
cat: () => true,
|
|
154
|
+
head: () => true,
|
|
155
|
+
wc: () => true,
|
|
156
|
+
grep: () => true,
|
|
157
|
+
pwd: (words) => words.length === 1,
|
|
158
|
+
tail: (words) =>
|
|
159
|
+
!words.some(
|
|
160
|
+
(word) =>
|
|
161
|
+
/^-[a-zA-Z]*[fF]/.test(word) || word.startsWith('--follow')
|
|
162
|
+
),
|
|
163
|
+
rg: (words) => !words.some((word) => word.startsWith('--pre')),
|
|
164
|
+
find: (words) => !words.some((word) => FIND_WRITES.includes(word)),
|
|
165
|
+
sed: (words) =>
|
|
166
|
+
words[1] === '-n' &&
|
|
167
|
+
SED_PRINT.test(words[2] ?? '') &&
|
|
168
|
+
words.slice(3).every((word) => !word.startsWith('-')),
|
|
169
|
+
git: (words) =>
|
|
170
|
+
GIT_READ_SUBCOMMANDS.includes(words[1] ?? '') &&
|
|
171
|
+
!words.some(
|
|
172
|
+
(word) => word.startsWith('--output') || word === '--ext-diff'
|
|
173
|
+
),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The read-only commands, as a person reads them. */
|
|
177
|
+
export const READ_ONLY_COMMANDS = [
|
|
178
|
+
'ls',
|
|
179
|
+
'cat',
|
|
180
|
+
'head',
|
|
181
|
+
'tail',
|
|
182
|
+
'wc',
|
|
183
|
+
'grep',
|
|
184
|
+
'rg',
|
|
185
|
+
'find (no -exec, -delete, or -fprint)',
|
|
186
|
+
'pwd',
|
|
187
|
+
"sed -n '<from>,<to>p' <file>",
|
|
188
|
+
`git ${GIT_READ_SUBCOMMANDS.join('|')} (no options before the subcommand)`,
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
/** Package managers other than Bun: any use of one may install. */
|
|
192
|
+
export const PACKAGE_MANAGERS = ['npm', 'npx', 'yarn', 'pnpm', 'pnpx']
|
|
193
|
+
|
|
194
|
+
/** `bun` subcommands that install, remove, or fetch packages. */
|
|
195
|
+
const BUN_INSTALLS = [
|
|
196
|
+
'install',
|
|
197
|
+
'i',
|
|
198
|
+
'add',
|
|
199
|
+
'a',
|
|
200
|
+
'remove',
|
|
201
|
+
'rm',
|
|
202
|
+
'update',
|
|
203
|
+
'upgrade',
|
|
204
|
+
'link',
|
|
205
|
+
'unlink',
|
|
206
|
+
'pm',
|
|
207
|
+
'patch',
|
|
208
|
+
'create',
|
|
209
|
+
'x',
|
|
210
|
+
]
|
|
211
|
+
|
|
212
|
+
/** Bun's flags that turn on installing missing packages in any command. */
|
|
213
|
+
const isInstallFlag = (word: string): boolean =>
|
|
214
|
+
word === '-i' || word === '--install' || word.startsWith('--install=')
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether a command runs a package install, or may: a package manager, a
|
|
218
|
+
* `bun` install subcommand, `bunx`, or `bun` with an auto-install flag.
|
|
219
|
+
*/
|
|
220
|
+
const isInstallCommand = (words: string[]): boolean => {
|
|
221
|
+
const [name, sub] = words
|
|
222
|
+
if (name === undefined) return false
|
|
223
|
+
if (PACKAGE_MANAGERS.includes(name) || name === 'bunx') return true
|
|
224
|
+
if (name !== 'bun') return false
|
|
225
|
+
return BUN_INSTALLS.includes(sub ?? '') || words.some(isInstallFlag)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const INSTALL_DENIED =
|
|
229
|
+
'The engine runs the package install, never an agent: if you change a package manifest, the engine installs after your turn and commits the lockfile.'
|
|
230
|
+
|
|
231
|
+
const checkRm = ({
|
|
232
|
+
role,
|
|
233
|
+
may_edit_tests,
|
|
234
|
+
words,
|
|
235
|
+
worktree,
|
|
236
|
+
config,
|
|
237
|
+
}: GuardArgs & {
|
|
238
|
+
words: Word[]
|
|
239
|
+
worktree: string
|
|
240
|
+
config: EngineConfig
|
|
241
|
+
}): ToolDecision => {
|
|
242
|
+
if (!isWriter(role)) return deny(`A ${role} may not delete files.`)
|
|
243
|
+
const paths: Word[] = []
|
|
244
|
+
let options = true
|
|
245
|
+
for (const word of words.slice(1)) {
|
|
246
|
+
if (options && word.text === '--') {
|
|
247
|
+
options = false
|
|
248
|
+
continue
|
|
249
|
+
}
|
|
250
|
+
if (options && word.text.startsWith('-')) {
|
|
251
|
+
if (word.text !== '-f') {
|
|
252
|
+
return deny(
|
|
253
|
+
'rm takes only -f: delete files one by one, not folders.'
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
paths.push(word)
|
|
259
|
+
}
|
|
260
|
+
if (paths.length === 0) return deny('rm needs at least one path.')
|
|
261
|
+
for (const word of paths) {
|
|
262
|
+
const inner = insideWorktree({ worktree, path: word.text })
|
|
263
|
+
if (word.expands || inner === null) {
|
|
264
|
+
return deny(
|
|
265
|
+
`rm ${word.text}: name each file inside your worktree, with no wildcards or ~.`
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
if (!mayWrite({ role, may_edit_tests, path: inner, config })) {
|
|
269
|
+
return deny(`A ${role} may not delete ${inner}.`)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return ALLOW
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const shellHelp = ({
|
|
276
|
+
role,
|
|
277
|
+
config,
|
|
278
|
+
}: {
|
|
279
|
+
role: GuardRole
|
|
280
|
+
config: EngineConfig
|
|
281
|
+
}): string => {
|
|
282
|
+
const { tests, others } = checkCommands({ role, config })
|
|
283
|
+
const checks = [
|
|
284
|
+
...tests.map(({ run, results }) =>
|
|
285
|
+
results === 'bun' ? `${run} [args]` : run
|
|
286
|
+
),
|
|
287
|
+
...others,
|
|
288
|
+
...(isWriter(role) ? ['rm [-f] <files you may write>'] : []),
|
|
289
|
+
]
|
|
290
|
+
return [
|
|
291
|
+
`A ${role} may run: ${[...checks, ...READ_ONLY_COMMANDS].join('; ')}.`,
|
|
292
|
+
'One command per call.',
|
|
293
|
+
].join(' ')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const checkBash = ({
|
|
297
|
+
role,
|
|
298
|
+
may_edit_tests,
|
|
299
|
+
command,
|
|
300
|
+
worktree,
|
|
301
|
+
config,
|
|
302
|
+
}: GuardArgs & {
|
|
303
|
+
command: string
|
|
304
|
+
worktree: string
|
|
305
|
+
config: EngineConfig
|
|
306
|
+
}): ToolDecision => {
|
|
307
|
+
if (role === 'learner') return deny('The learner has no shell.')
|
|
308
|
+
const trimmed = command.trim()
|
|
309
|
+
const { tests, others } = checkCommands({ role, config })
|
|
310
|
+
// The config's own commands may use shell syntax; only exact copies run.
|
|
311
|
+
if (tests.some(({ run }) => run === trimmed) || others.includes(trimmed)) {
|
|
312
|
+
return ALLOW
|
|
313
|
+
}
|
|
314
|
+
const split = splitCommand(trimmed)
|
|
315
|
+
if (!split.ok) return deny(`${split.error} ${shellHelp({ role, config })}`)
|
|
316
|
+
const words = split.words.map(({ text }) => text)
|
|
317
|
+
const [name] = words
|
|
318
|
+
if (name === undefined) return deny('An empty command.')
|
|
319
|
+
const exact = exactCommands({ tests, others })
|
|
320
|
+
if (exact.some((other) => wordsOf(other)?.join(' ') === words.join(' '))) {
|
|
321
|
+
return ALLOW
|
|
322
|
+
}
|
|
323
|
+
if (isInstallCommand(words)) return deny(INSTALL_DENIED)
|
|
324
|
+
for (const { run, results } of tests) {
|
|
325
|
+
const testWords = results === 'bun' ? wordsOf(run) : null
|
|
326
|
+
if (testWords !== null && startsWith({ words, prefix: testWords })) {
|
|
327
|
+
return ALLOW
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (name === 'rm') {
|
|
331
|
+
return checkRm({
|
|
332
|
+
role,
|
|
333
|
+
may_edit_tests,
|
|
334
|
+
words: split.words,
|
|
335
|
+
worktree,
|
|
336
|
+
config,
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
const readOnly = READ_ONLY[name]
|
|
340
|
+
if (readOnly !== undefined && readOnly(words)) return ALLOW
|
|
341
|
+
return deny(
|
|
342
|
+
`"${trimmed}" is not allowed. ${shellHelp({ role, config })} No git writes, GitHub, or network.`
|
|
343
|
+
)
|
|
344
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync, lstatSync } from 'node:fs'
|
|
3
|
+
import { rm } from 'node:fs/promises'
|
|
4
|
+
import { homedir, tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import uniq from 'lodash/uniq'
|
|
8
|
+
|
|
9
|
+
import { runCommand, type CommandResult } from '../shell/run-command'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Put before every git command the check runs, so an fsmonitor or hook
|
|
13
|
+
* someone planted in the shared `.git` never runs for the engine. Content
|
|
14
|
+
* filters are switched off too, by `noFilters`.
|
|
15
|
+
*/
|
|
16
|
+
const SAFE_GIT = [
|
|
17
|
+
'-c',
|
|
18
|
+
'core.fsmonitor=false',
|
|
19
|
+
'-c',
|
|
20
|
+
'core.hooksPath=/dev/null',
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
export const nulSplit = (text: string): string[] =>
|
|
24
|
+
text.split('\0').filter((part) => part.length > 0)
|
|
25
|
+
|
|
26
|
+
/** The environment variables `noFilters` points git's settings at. */
|
|
27
|
+
const NO_FILTER_ENV = { LUCA_GIT_EMPTY: '', LUCA_GIT_FALSE: 'false' }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Every content filter (`filter.<name>.*`) git's config names, read fresh
|
|
31
|
+
* for each command, since another process may add one at any time.
|
|
32
|
+
*/
|
|
33
|
+
const filterNames = async ({ cwd }: { cwd: string }): Promise<string[]> => {
|
|
34
|
+
const listed = await runCommand({
|
|
35
|
+
cmd: ['git', ...SAFE_GIT, 'config', '-z', '--get-regexp', '^filter\\.'],
|
|
36
|
+
cwd,
|
|
37
|
+
timeout_ms: 60_000,
|
|
38
|
+
})
|
|
39
|
+
return uniq(
|
|
40
|
+
nulSplit(listed.stdout).flatMap((entry) => {
|
|
41
|
+
const key = entry.split('\n')[0] ?? ''
|
|
42
|
+
const last = key.lastIndexOf('.')
|
|
43
|
+
// `>=` keeps the empty name (`filter..clean`): git runs a filter named "".
|
|
44
|
+
return last >= 'filter.'.length
|
|
45
|
+
? [key.slice('filter.'.length, last)]
|
|
46
|
+
: []
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Options that switch each named filter off: no clean, smudge, or process
|
|
53
|
+
* command, and not required (git gives up on a required filter with no
|
|
54
|
+
* command). Set through `--config-env`, which takes any filter name, even
|
|
55
|
+
* one with `=` in it.
|
|
56
|
+
*/
|
|
57
|
+
const noFilters = (names: string[]): string[] =>
|
|
58
|
+
names.flatMap((name) => [
|
|
59
|
+
`--config-env=filter.${name}.clean=LUCA_GIT_EMPTY`,
|
|
60
|
+
`--config-env=filter.${name}.smudge=LUCA_GIT_EMPTY`,
|
|
61
|
+
`--config-env=filter.${name}.process=LUCA_GIT_EMPTY`,
|
|
62
|
+
`--config-env=filter.${name}.required=LUCA_GIT_FALSE`,
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
/** A git run, and why git could not start (such as `cwd` being gone). */
|
|
66
|
+
type GitRun = CommandResult & { start_error: string | null }
|
|
67
|
+
|
|
68
|
+
const runGit = async ({
|
|
69
|
+
cwd,
|
|
70
|
+
args,
|
|
71
|
+
}: {
|
|
72
|
+
cwd: string
|
|
73
|
+
args: string[]
|
|
74
|
+
}): Promise<GitRun> => {
|
|
75
|
+
try {
|
|
76
|
+
const names = await filterNames({ cwd })
|
|
77
|
+
const result = await runCommand({
|
|
78
|
+
cmd: ['git', ...SAFE_GIT, ...noFilters(names), ...args],
|
|
79
|
+
cwd,
|
|
80
|
+
timeout_ms: 60_000,
|
|
81
|
+
env: NO_FILTER_ENV,
|
|
82
|
+
})
|
|
83
|
+
return { ...result, start_error: null }
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return {
|
|
86
|
+
exit_code: null,
|
|
87
|
+
stdout: '',
|
|
88
|
+
stderr: '',
|
|
89
|
+
timed_out: false,
|
|
90
|
+
start_error: error instanceof Error ? error.message : String(error),
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const git = async ({
|
|
96
|
+
cwd,
|
|
97
|
+
args,
|
|
98
|
+
}: {
|
|
99
|
+
cwd: string
|
|
100
|
+
args: string[]
|
|
101
|
+
}): Promise<{ ok: boolean; stdout: string }> => {
|
|
102
|
+
const result = await runGit({ cwd, args })
|
|
103
|
+
return { ok: result.exit_code === 0, stdout: result.stdout }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Why a git run failed, said even when git printed nothing. */
|
|
107
|
+
const gitFailure = ({ cwd, run }: { cwd: string; run: GitRun }): string =>
|
|
108
|
+
[
|
|
109
|
+
run.start_error !== null
|
|
110
|
+
? `could not start (${run.start_error})`
|
|
111
|
+
: run.timed_out
|
|
112
|
+
? 'timed out'
|
|
113
|
+
: run.exit_code === null
|
|
114
|
+
? 'killed'
|
|
115
|
+
: `exit code ${run.exit_code}`,
|
|
116
|
+
...(existsSync(cwd) ? [] : ['the folder no longer exists']),
|
|
117
|
+
].join(', ')
|
|
118
|
+
|
|
119
|
+
export const gitOk = async ({
|
|
120
|
+
cwd,
|
|
121
|
+
args,
|
|
122
|
+
}: {
|
|
123
|
+
cwd: string
|
|
124
|
+
args: string[]
|
|
125
|
+
}): Promise<string> => {
|
|
126
|
+
const result = await runGit({ cwd, args })
|
|
127
|
+
if (result.exit_code !== 0) {
|
|
128
|
+
const output = (result.stderr || result.stdout).trim()
|
|
129
|
+
throw new Error(
|
|
130
|
+
`git ${args.join(' ')} failed in ${cwd}: ${gitFailure({ cwd, run: result })}${output === '' ? '' : `\n${output}`}`
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return result.stdout
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The global excludes file git would use. */
|
|
137
|
+
export const globalExcludesFile = async ({
|
|
138
|
+
cwd,
|
|
139
|
+
}: {
|
|
140
|
+
cwd: string
|
|
141
|
+
}): Promise<string> => {
|
|
142
|
+
const set = await git({
|
|
143
|
+
cwd,
|
|
144
|
+
args: ['config', '--path', '--get', 'core.excludesFile'],
|
|
145
|
+
})
|
|
146
|
+
const xdg = process.env.XDG_CONFIG_HOME
|
|
147
|
+
return set.ok
|
|
148
|
+
? set.stdout.trim()
|
|
149
|
+
: join(
|
|
150
|
+
xdg !== undefined && xdg !== ''
|
|
151
|
+
? xdg
|
|
152
|
+
: join(homedir(), '.config'),
|
|
153
|
+
'git/ignore'
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The ignore rules git would use but for `.gitignore` files: the global
|
|
159
|
+
* excludes file, then the shared `.git/info/exclude` (whose lines win).
|
|
160
|
+
*/
|
|
161
|
+
export const excludeRules = async ({
|
|
162
|
+
global,
|
|
163
|
+
common_dir,
|
|
164
|
+
}: {
|
|
165
|
+
global: string
|
|
166
|
+
common_dir: string
|
|
167
|
+
}): Promise<Uint8Array> => {
|
|
168
|
+
const read = async (full: string) =>
|
|
169
|
+
existsSync(full) && lstatSync(full).isFile()
|
|
170
|
+
? Bun.file(full).text()
|
|
171
|
+
: ''
|
|
172
|
+
const [globalRules, shared] = await Promise.all([
|
|
173
|
+
read(global),
|
|
174
|
+
read(join(common_dir, 'info/exclude')),
|
|
175
|
+
])
|
|
176
|
+
return new TextEncoder().encode(`${globalRules}\n${shared}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Runs `use` with the rules written to a temp file, removed after. */
|
|
180
|
+
export const withExcludeFile = async <Result>({
|
|
181
|
+
rules,
|
|
182
|
+
use,
|
|
183
|
+
}: {
|
|
184
|
+
rules: Uint8Array
|
|
185
|
+
use: (file: string) => Promise<Result>
|
|
186
|
+
}): Promise<Result> => {
|
|
187
|
+
const file = join(tmpdir(), `luca-engine-exclude-${randomUUID()}`)
|
|
188
|
+
await Bun.write(file, rules)
|
|
189
|
+
try {
|
|
190
|
+
return await use(file)
|
|
191
|
+
} finally {
|
|
192
|
+
await rm(file, { force: true })
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The `ls-files` options that ignore by the snapshot's rules. */
|
|
197
|
+
export const excludeArgs = (file: string): string[] => [
|
|
198
|
+
`--exclude-from=${file}`,
|
|
199
|
+
'--exclude-per-directory=.gitignore',
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
export const commonDirOf = async ({ cwd }: { cwd: string }): Promise<string> =>
|
|
203
|
+
(
|
|
204
|
+
await gitOk({
|
|
205
|
+
cwd,
|
|
206
|
+
args: ['rev-parse', '--path-format=absolute', '--git-common-dir'],
|
|
207
|
+
})
|
|
208
|
+
).trim()
|