jackherd 1.16.0 → 1.18.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.
- package/CHANGELOG.md +28 -0
- package/INSTALL.md +2 -1
- package/README.md +29 -33
- package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
- package/dist/config.d.ts +33 -1
- package/dist/config.js +57 -5
- package/dist/plan-format.d.ts +18 -6
- package/dist/plan-format.js +8 -1
- package/docs/plan-contract.md +1 -0
- package/herdr-plugin/herdr-plugin.toml +4 -1
- package/herdr-plugin/restore-plans.ts +60 -26
- package/herdr-plugin/resume.ts +18 -0
- package/install.sh +1 -1
- package/kit/hooks/ask-rules.json +11 -0
- package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
- package/kit/hooks/hooks/approval-gate/index.ts +36 -7
- package/kit/hooks/hooks/approval-gate/run.ts +72 -16
- package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
- package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
- package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
- package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
- package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
- package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
- package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
- package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
- package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +423 -35
- package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
- package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
- package/kit/hooks/hooks/merge-guard/run.ts +110 -28
- package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
- package/kit/hooks/hooks/plain-question/form.ts +89 -0
- package/kit/hooks/hooks/plain-question/index.ts +44 -4
- package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
- package/kit/hooks/hooks/plain-question/run.ts +14 -7
- package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
- package/kit/hooks/hooks.json +11 -2
- package/kit/review/agents/skill-review.md +3 -2
- package/kit/review/skills/pre-commit/SKILL.md +16 -22
- package/kit/rules/rules/kit/code-style.md +26 -54
- package/kit/rules/rules/kit/pull-requests.md +17 -40
- package/kit/rules/rules/kit/testing.md +6 -12
- package/kit/rules/rules/kit/writing-for-agents.md +3 -0
- package/kit/workflow/skills/follow/SKILL.md +2 -3
- package/kit/workflow/skills/implement/SKILL.md +28 -43
- package/kit/workflow/skills/orchestrate/SKILL.md +201 -458
- package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
- package/kit/workflow/skills/orchestrate/references/approvals.md +23 -0
- package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
- package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
- package/kit/workflow/skills/orchestrate/references/events.md +46 -1
- package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
- package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
- package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
- package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
- package/kit/workflow/skills/orchestrate/references/plan-commit.md +10 -2
- package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
- package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
- package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
- package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
- package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
- package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
- package/kit/workflow/skills/plan/SKILL.md +29 -48
- package/package.json +2 -1
- package/recipes/README.md +1 -0
- package/recipes/jira-cli.md +1 -1
- package/recipes/sources/google-calendar-today.ts +8 -2
- package/recipes/sources/jira.ts +10 -2
- package/recipes/sources/linear.ts +9 -3
- package/recipes/sources/sentry.ts +8 -2
- package/recipes/sources.md +4 -2
- package/schemas/jackherd.schema.json +67 -0
- package/schemas/sources.schema.json +9 -0
- package/src/approval/checked.ts +60 -0
- package/src/approval/command.ts +16 -0
- package/src/check/command.ts +51 -6
- package/src/check/lint-pointers.ts +79 -0
- package/src/check/lint-shaped.ts +196 -0
- package/src/check/plan-structure-check.ts +116 -4
- package/src/check/report.ts +9 -3
- package/src/check/schema.ts +37 -0
- package/src/check/settings.ts +4 -0
- package/src/check/skill-budget-defaults.ts +90 -32
- package/src/check/skill-budgets.ts +96 -18
- package/src/cli.ts +2 -0
- package/src/config/personal-sources.ts +46 -10
- package/src/config/repository-identity.ts +40 -0
- package/src/config/schema.ts +53 -1
- package/src/context/command.ts +33 -2
- package/src/context/options.ts +33 -8
- package/src/context/usage-logs.ts +53 -0
- package/src/context/usage.ts +110 -0
- package/src/lane/base-command.ts +35 -0
- package/src/lane/base.ts +43 -0
- package/src/lane/close-check.ts +49 -15
- package/src/lane/close-command.ts +71 -11
- package/src/lane/close-options.ts +29 -8
- package/src/lane/close-survey.ts +13 -1
- package/src/lane/close.ts +51 -12
- package/src/lane/command.ts +21 -3
- package/src/lane/send-command.ts +32 -0
- package/src/lane/send-herdr.ts +77 -0
- package/src/lane/send-options.ts +23 -0
- package/src/lane/send.ts +90 -0
- package/src/lane/skill-hint.ts +27 -0
- package/src/lane/space-command.ts +30 -0
- package/src/lane/space.ts +49 -0
- package/src/lane/start-lane-hint.ts +18 -0
- package/src/plan-contract/base-check-git.ts +33 -0
- package/src/plan-contract/base-check.ts +17 -0
- package/src/plan-contract/command.ts +25 -4
- package/src/plan-format/plan-structure.ts +17 -5
- package/src/plans/ask-coordinator.ts +23 -13
- package/src/plans/board-rows.ts +9 -4
- package/src/plans/click-hint.ts +4 -0
- package/src/plans/command.ts +6 -5
- package/src/plans/ensure-panes.ts +14 -3
- package/src/plans/git.ts +14 -0
- package/src/plans/github.ts +19 -10
- package/src/plans/layout.ts +82 -33
- package/src/plans/live.ts +8 -12
- package/src/plans/plan-panes.ts +21 -4
- package/src/plans/selection.ts +29 -0
- package/src/plans/settings.ts +4 -0
- package/src/resume/command.ts +213 -0
- package/src/resume/decision.ts +102 -0
- package/src/resume/keeper.ts +65 -0
- package/src/resume/location.ts +22 -0
- package/src/resume/log.ts +27 -0
- package/src/resume/ports.ts +102 -0
- package/src/resume/record.ts +116 -0
- package/src/resume/targets.ts +84 -0
- package/src/resume/texts.ts +27 -0
- package/src/secrets/command.ts +15 -5
- package/src/sync/herdr-plugin.ts +2 -11
- package/src/sync/plan-ask-rules.ts +11 -0
- package/src/sync/render.ts +3 -1
- package/src/vocabulary/index.ts +13 -0
- package/src/waiting/command.ts +13 -4
- package/src/watch/command.ts +177 -78
- package/src/watch/config-reload.ts +119 -0
- package/src/watch/lane-model.ts +11 -1
- package/src/watch/lane-progress.ts +70 -8
- package/src/watch/lane-report.ts +66 -11
- package/src/watch/main-checkout.ts +5 -1
- package/src/watch/merge-duties.ts +33 -0
- package/src/watch/merge-overlap-read.ts +69 -0
- package/src/watch/plan-views.ts +16 -4
- package/src/watch/reference-paths.ts +46 -5
- package/src/watch/report-questions.ts +5 -2
- package/src/watch/single-report.ts +3 -2
- package/src/watch/source-dry-run-command.ts +22 -4
- package/src/watch/source-reason.ts +58 -0
- package/src/watch/source-reload.ts +21 -17
- package/src/watch/source-run.ts +24 -7
- package/src/watch/sources.ts +9 -2
- package/src/watch/texts.ts +14 -3
- package/src/watch/waiting-line.ts +4 -3
|
@@ -2,6 +2,7 @@ import { FULL_COMMIT } from '../shared/commit'
|
|
|
2
2
|
import { inlineScript, isOption, isProgramPosition, programName } from '../shared/program-position'
|
|
3
3
|
import {
|
|
4
4
|
expandingHereDocumentAdvice,
|
|
5
|
+
expandsBraces,
|
|
5
6
|
isExpanded,
|
|
6
7
|
parseShell,
|
|
7
8
|
ShellParseError,
|
|
@@ -17,6 +18,15 @@ import {
|
|
|
17
18
|
shellDefinitionHint,
|
|
18
19
|
} from '../shared/shell-aliases'
|
|
19
20
|
import { GLOB, resolvePlainXargs, XARGS } from '../shared/xargs-program'
|
|
21
|
+
import {
|
|
22
|
+
AGENT_INPUT_COMMANDS,
|
|
23
|
+
AGENT_TARGET,
|
|
24
|
+
HERD_WORDS,
|
|
25
|
+
LIFTABLE_PANE_INPUT,
|
|
26
|
+
OTHER_SERVER_OPTIONS,
|
|
27
|
+
PANE_ID,
|
|
28
|
+
PANE_INPUT_COMMANDS,
|
|
29
|
+
} from './pane-input'
|
|
20
30
|
|
|
21
31
|
export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
|
|
22
32
|
|
|
@@ -33,27 +43,75 @@ export type GuardPolicy = {
|
|
|
33
43
|
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
34
44
|
commands: readonly string[]
|
|
35
45
|
/**
|
|
36
|
-
* Whether the merge of this pull request may pass without a question: the user approved its branch
|
|
37
|
-
*
|
|
46
|
+
* Whether the merge of this pull request may pass without a question: the user approved its branch with the change
|
|
47
|
+
* id (patch id of its diff) the pull request still carries, a standing approval covers the plan of its lane, or
|
|
48
|
+
* every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
|
|
38
49
|
* A throw counts as not approved; a note is not approved either, and the question carries it.
|
|
39
50
|
*/
|
|
40
51
|
isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
|
|
52
|
+
/**
|
|
53
|
+
* Whether the pane a plain `herdr pane send-text|send-keys|run <pane id>` writes into cannot be the herd. Asked only
|
|
54
|
+
* for that plain form; absent or throwing, every input into a pane asks.
|
|
55
|
+
*/
|
|
56
|
+
isPaneSafe?: (pane: string) => boolean
|
|
57
|
+
/**
|
|
58
|
+
* Whether the agent a plain `herdr agent send-keys|prompt <target>` writes into cannot be the coordinator, whose open
|
|
59
|
+
* dialog a key would answer. Absent or throwing, every input into an agent asks.
|
|
60
|
+
*/
|
|
61
|
+
isAgentSafe?: (target: string) => boolean
|
|
41
62
|
/** The aliases and functions of the user's shell, read only when a word at a program position may be one; none by default. */
|
|
42
63
|
shellDefinitions?: () => ShellDefinitions
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
/**
|
|
46
67
|
* A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
|
|
47
|
-
* the shell alias or function that may run one (`definition`, whose name is then the `command`).
|
|
68
|
+
* the shell alias or function that may run one (`definition`, whose name is then the `command`). `sole` where it was
|
|
69
|
+
* the one command of the call, read as written by `gh`; `pane` the pane id an input into a pane names in its plain
|
|
70
|
+
* form, `input` the words it writes there; `agent` the target an input into an agent names in its plain form.
|
|
48
71
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
type Finding = {
|
|
73
|
+
command: string
|
|
74
|
+
merge: PlainMerge | undefined
|
|
75
|
+
definition?: ShellDefinitionHit
|
|
76
|
+
sole?: boolean
|
|
77
|
+
pane?: string
|
|
78
|
+
input?: string
|
|
79
|
+
agent?: string
|
|
80
|
+
}
|
|
51
81
|
|
|
52
82
|
/** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
|
|
53
83
|
type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
|
|
54
84
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
85
|
+
/**
|
|
86
|
+
* The parts of a `gh api` call that decide whether it writes, and where: the method it names, whether a header
|
|
87
|
+
* overrides it, the endpoints, the values of its field options (`fields`), and whether a file or stdin gives the body
|
|
88
|
+
* (`input`). A field or an input makes the method POST unless one is named.
|
|
89
|
+
*/
|
|
90
|
+
type ApiCall = {
|
|
91
|
+
method: Word | undefined
|
|
92
|
+
override: boolean
|
|
93
|
+
endpoints: Word[]
|
|
94
|
+
fields: Word[]
|
|
95
|
+
input: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The parts of a `curl` call that decide whether it writes, and where: the method it names, whether it sends data
|
|
100
|
+
* (which `-G` turns into a query) or uploads (which `-G` leaves a body), whether a header overrides the method, whether
|
|
101
|
+
* a config file or an unreadable word may add any option, and the URLs it may reach.
|
|
102
|
+
*/
|
|
103
|
+
type CurlCall = {
|
|
104
|
+
method: Word | undefined
|
|
105
|
+
data: boolean
|
|
106
|
+
upload: boolean
|
|
107
|
+
get: boolean
|
|
108
|
+
override: boolean
|
|
109
|
+
unknown: boolean
|
|
110
|
+
targets: Word[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** An option split into its name and the value attached to it, if any. */
|
|
114
|
+
type SplitOption = { name: string; attached: Word | undefined }
|
|
57
115
|
|
|
58
116
|
/** The one command whose guard an approval can lift. Every other guarded command asks each time. */
|
|
59
117
|
const MERGE_COMMAND = 'gh pr merge'
|
|
@@ -118,6 +176,82 @@ const DANGEROUS_ENDPOINTS = [
|
|
|
118
176
|
/(^|\/)actions\/(runs|jobs)\/[^/]+\/(rerun|rerun-failed-jobs)\/?$/,
|
|
119
177
|
/(^|\/)git\/refs(\/|$)/,
|
|
120
178
|
]
|
|
179
|
+
const GRAPHQL_ENDPOINT = /(^|\/)graphql\/?$/
|
|
180
|
+
const API_INPUT_OPTION = '--input'
|
|
181
|
+
/** The GraphQL mutations that do what a dangerous endpoint does: merge, commit, move a ref, rerun the checks. */
|
|
182
|
+
const DANGEROUS_MUTATIONS =
|
|
183
|
+
/\b(mergePullRequest|mergeBranch|enablePullRequestAutoMerge|enqueuePullRequest|createCommitOnBranch|createRef|updateRefs?|deleteRef|rerequestCheckSuite)\b/
|
|
184
|
+
/** A query field whose text is not in the command: read from a file (`@file`) or nothing but expansions. */
|
|
185
|
+
const UNKNOWN_QUERY = /^query=(@|(\$(\{[^}]*\}|\w+)?)+$)/
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `curl` is guarded whatever `guard.commands` says, for a write against the GitHub API: it merges, commits or deploys
|
|
189
|
+
* as `gh api` does, and no project lists it. A read passes.
|
|
190
|
+
*/
|
|
191
|
+
const CURL_COMMAND = 'curl'
|
|
192
|
+
const GITHUB_API_HOST = 'api.github.com'
|
|
193
|
+
const CURL_READ_METHODS = new Set(['GET', 'HEAD'])
|
|
194
|
+
const CURL_METHOD_OPTIONS = new Set(['-X', '--request'])
|
|
195
|
+
/** Options that send data: POST, unless `-G` turns the data into a query string. */
|
|
196
|
+
const CURL_DATA_OPTIONS = new Set([
|
|
197
|
+
'-d',
|
|
198
|
+
'--data',
|
|
199
|
+
'--data-ascii',
|
|
200
|
+
'--data-binary',
|
|
201
|
+
'--data-raw',
|
|
202
|
+
'--data-urlencode',
|
|
203
|
+
'--json',
|
|
204
|
+
])
|
|
205
|
+
/** Options that send a body whatever `-G` says: a form is a POST, an upload a PUT. */
|
|
206
|
+
const CURL_UPLOAD_OPTIONS = new Set(['-F', '--form', '--form-string', '-T', '--upload-file'])
|
|
207
|
+
/** Options that turn the data into a query string, or ask for the head only. */
|
|
208
|
+
const CURL_GET_OPTIONS = new Set(['-G', '--get', '-I', '--head'])
|
|
209
|
+
/** A config file can name any option, a method and data among them. */
|
|
210
|
+
const CURL_CONFIG_OPTIONS = new Set(['-K', '--config'])
|
|
211
|
+
const CURL_HEADER_OPTIONS = new Set(['-H', '--header'])
|
|
212
|
+
const CURL_URL_OPTION = '--url'
|
|
213
|
+
/** `--expand-<option>` is `<option>` with variables expanded in its value (curl 8.3). */
|
|
214
|
+
const CURL_EXPAND_PREFIX = '--expand-'
|
|
215
|
+
/**
|
|
216
|
+
* Every command `decideBashCommand` guards whatever `guard.commands` says, each with its own way to pass:
|
|
217
|
+
* `isGuardedUse` for curl, `isPaneSafe` and `isAgentSafe` for the inputs into a pane or an agent.
|
|
218
|
+
*/
|
|
219
|
+
const FIXED_COMMANDS: readonly string[] = [...PANE_INPUT_COMMANDS, ...AGENT_INPUT_COMMANDS, CURL_COMMAND]
|
|
220
|
+
/** The short options of curl that take a value: in a group (`-sXPUT`, `-sX PUT`) the rest of the group or the next word. */
|
|
221
|
+
const CURL_SHORT_WITH_VALUE = new Set('AbcCdDeEFHKmoPQrtTuUwxXyYz')
|
|
222
|
+
const CURL_LONG_WITH_VALUE = new Set([
|
|
223
|
+
...CURL_METHOD_OPTIONS,
|
|
224
|
+
...CURL_DATA_OPTIONS,
|
|
225
|
+
...CURL_UPLOAD_OPTIONS,
|
|
226
|
+
...CURL_CONFIG_OPTIONS,
|
|
227
|
+
...CURL_HEADER_OPTIONS,
|
|
228
|
+
CURL_URL_OPTION,
|
|
229
|
+
'--user',
|
|
230
|
+
'--output',
|
|
231
|
+
'--cookie',
|
|
232
|
+
'--cookie-jar',
|
|
233
|
+
'--write-out',
|
|
234
|
+
'--max-time',
|
|
235
|
+
'--connect-timeout',
|
|
236
|
+
'--retry',
|
|
237
|
+
'--retry-delay',
|
|
238
|
+
'--retry-max-time',
|
|
239
|
+
'--user-agent',
|
|
240
|
+
'--referer',
|
|
241
|
+
'--oauth2-bearer',
|
|
242
|
+
'--variable',
|
|
243
|
+
'--proxy',
|
|
244
|
+
'--cacert',
|
|
245
|
+
'--cert',
|
|
246
|
+
'--key',
|
|
247
|
+
'--resolve',
|
|
248
|
+
'--connect-to',
|
|
249
|
+
'--limit-rate',
|
|
250
|
+
'--range',
|
|
251
|
+
'--dump-header',
|
|
252
|
+
'--interface',
|
|
253
|
+
])
|
|
254
|
+
|
|
121
255
|
/** What the fixed end of an endpoint after an expansion may be, when the expansion can stand for the rest of the path. */
|
|
122
256
|
const DANGEROUS_ENDINGS = [
|
|
123
257
|
/(^|\/)merges?\/?$/,
|
|
@@ -203,33 +337,57 @@ const isOpaque = ({ value }: Word): boolean => isExpanded(value) && value.replac
|
|
|
203
337
|
|
|
204
338
|
const isReadMethod = ({ value }: Word): boolean => !isExpanded(value) && value.toUpperCase() === READ_METHOD
|
|
205
339
|
|
|
206
|
-
/**
|
|
207
|
-
const
|
|
208
|
-
if (value.startsWith('-X') && value.length > 2) return { name: '-X', attached: { value: value.slice(2), literal } }
|
|
340
|
+
/** A long option split at its first `=`: `--name=value`. */
|
|
341
|
+
const splitAttached = ({ value, literal }: Word): SplitOption => {
|
|
209
342
|
const at = value.indexOf('=')
|
|
210
343
|
if (at === -1 || !value.startsWith('--')) return { name: value, attached: undefined }
|
|
211
344
|
return { name: value.slice(0, at), attached: { value: value.slice(at + 1), literal } }
|
|
212
345
|
}
|
|
213
346
|
|
|
347
|
+
/** An option of `gh api` split into its name and the value attached with `=` (or glued to `-X`), if any. */
|
|
348
|
+
const apiOption = (word: Word): SplitOption =>
|
|
349
|
+
word.value.startsWith('-X') && word.value.length > 2
|
|
350
|
+
? { name: '-X', attached: { value: word.value.slice(2), literal: word.literal } }
|
|
351
|
+
: splitAttached(word)
|
|
352
|
+
|
|
214
353
|
/** A short body option with its value glued on, as in `-fbody=x`. */
|
|
215
354
|
const isGluedBodyOption = (value: string): boolean => /^-[fF]./.test(value)
|
|
216
355
|
|
|
356
|
+
/** The value of a field option: attached with `=`, glued to `-f`, or the next word. */
|
|
357
|
+
const fieldValue = (name: string, attached: Word | undefined, next: Word | undefined, word: Word): Word | undefined => {
|
|
358
|
+
if (attached !== undefined) return attached
|
|
359
|
+
return isGluedBodyOption(name) ? { value: name.slice(2), literal: word.literal } : next
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const isOverrideHeader = (header: Word | undefined): boolean =>
|
|
363
|
+
header !== undefined && METHOD_OVERRIDE_HEADER.test(header.value)
|
|
364
|
+
|
|
365
|
+
/** Adds what one option of `gh api` does to the call; `value` is its attached value or the next word. */
|
|
366
|
+
const readApiOption = (call: ApiCall, { name, attached }: SplitOption, word: Word, next: Word | undefined): void => {
|
|
367
|
+
const value = attached ?? next
|
|
368
|
+
if (API_METHOD_OPTIONS.has(name)) call.method = value
|
|
369
|
+
if (name === API_INPUT_OPTION) call.input = true
|
|
370
|
+
else if (API_BODY_OPTIONS.has(name) || isGluedBodyOption(name)) {
|
|
371
|
+
const field = fieldValue(name, attached, next, word)
|
|
372
|
+
if (field !== undefined) call.fields.push(field)
|
|
373
|
+
}
|
|
374
|
+
if (HEADER_OPTIONS.has(name) && isOverrideHeader(value)) call.override = true
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const takesNextApiWord = ({ name, attached }: SplitOption): boolean =>
|
|
378
|
+
attached === undefined &&
|
|
379
|
+
(API_METHOD_OPTIONS.has(name) || API_BODY_OPTIONS.has(name) || API_OPTIONS_WITH_VALUE.has(name))
|
|
380
|
+
|
|
217
381
|
const readApiCall = (args: Word[]): ApiCall => {
|
|
218
|
-
const call: ApiCall = { method: undefined,
|
|
382
|
+
const call: ApiCall = { method: undefined, override: false, endpoints: [], fields: [], input: false }
|
|
219
383
|
for (let index = 0; index < args.length; index++) {
|
|
220
384
|
if (!isOption(args[index])) {
|
|
221
385
|
call.endpoints.push(args[index])
|
|
222
386
|
continue
|
|
223
387
|
}
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
(API_METHOD_OPTIONS.has(name) || API_BODY_OPTIONS.has(name) || API_OPTIONS_WITH_VALUE.has(name))
|
|
228
|
-
if (API_METHOD_OPTIONS.has(name)) call.method = attached ?? args[index + 1]
|
|
229
|
-
if (API_BODY_OPTIONS.has(name) || isGluedBodyOption(name)) call.fields = true
|
|
230
|
-
const header = HEADER_OPTIONS.has(name) ? (attached ?? args[index + 1]) : undefined
|
|
231
|
-
if (header !== undefined && METHOD_OVERRIDE_HEADER.test(header.value)) call.override = true
|
|
232
|
-
if (takesNext) index++
|
|
388
|
+
const option = apiOption(args[index])
|
|
389
|
+
readApiOption(call, option, args[index], args[index + 1])
|
|
390
|
+
if (takesNextApiWord(option)) index++
|
|
233
391
|
}
|
|
234
392
|
return call
|
|
235
393
|
}
|
|
@@ -263,9 +421,96 @@ const isDangerousEndpoint = ({ value }: Word): boolean => {
|
|
|
263
421
|
*/
|
|
264
422
|
const writesDangerously = (args: Word[]): boolean => {
|
|
265
423
|
if (args.some(isOpaque)) return true
|
|
266
|
-
const
|
|
267
|
-
const
|
|
268
|
-
|
|
424
|
+
const call = readApiCall(args)
|
|
425
|
+
const { method, fields, input, override, endpoints } = call
|
|
426
|
+
const writes = override || (method === undefined ? fields.length > 0 || input : !isReadMethod(method))
|
|
427
|
+
return writes && (endpoints.some(isDangerousEndpoint) || runsDangerousMutation(call))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether a call to the GraphQL endpoint may run a mutation that merges, commits, moves a ref or reruns: its query
|
|
432
|
+
* names one, or its text is not in the command (a file, stdin, an expansion). A query that only reads passes.
|
|
433
|
+
*/
|
|
434
|
+
const runsDangerousMutation = ({ endpoints, fields, input }: ApiCall): boolean =>
|
|
435
|
+
endpoints.some(({ value }) => GRAPHQL_ENDPOINT.test(normalizedPath(value))) &&
|
|
436
|
+
(input || fields.some(({ value }) => UNKNOWN_QUERY.test(value) || DANGEROUS_MUTATIONS.test(value)))
|
|
437
|
+
|
|
438
|
+
const isCurlWriteMethod = ({ value }: Word): boolean =>
|
|
439
|
+
isExpanded(value) || !CURL_READ_METHODS.has(value.toUpperCase())
|
|
440
|
+
|
|
441
|
+
/** A curl option's name, with `--expand-<option>` read as `<option>`. */
|
|
442
|
+
const curlOptionName = (name: string): string =>
|
|
443
|
+
name.startsWith(CURL_EXPAND_PREFIX) ? `--${name.slice(CURL_EXPAND_PREFIX.length)}` : name
|
|
444
|
+
|
|
445
|
+
/** Adds what one option, with its value, does to the call. */
|
|
446
|
+
const readCurlOption = (call: CurlCall, name: string, value: Word | undefined): void => {
|
|
447
|
+
if (CURL_METHOD_OPTIONS.has(name)) call.method = value
|
|
448
|
+
if (CURL_DATA_OPTIONS.has(name)) call.data = true
|
|
449
|
+
if (CURL_UPLOAD_OPTIONS.has(name)) call.upload = true
|
|
450
|
+
if (CURL_GET_OPTIONS.has(name)) call.get = true
|
|
451
|
+
if (CURL_CONFIG_OPTIONS.has(name)) call.unknown = true
|
|
452
|
+
if (CURL_HEADER_OPTIONS.has(name) && isOverrideHeader(value)) call.override = true
|
|
453
|
+
if (name === CURL_URL_OPTION && value !== undefined) call.targets.push(value)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** A group of short options (`-sX PUT`, `-sXPUT`, `-sd x`): the first one that takes a value ends it. */
|
|
457
|
+
const readShortGroup = (call: CurlCall, word: Word, next: Word | undefined): boolean => {
|
|
458
|
+
const letters = word.value.slice(1)
|
|
459
|
+
for (let at = 0; at < letters.length; at++) {
|
|
460
|
+
const name = `-${letters[at]}`
|
|
461
|
+
if (!CURL_SHORT_WITH_VALUE.has(letters[at])) {
|
|
462
|
+
readCurlOption(call, name, undefined)
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
const rest = letters.slice(at + 1)
|
|
466
|
+
readCurlOption(call, name, rest === '' ? next : { value: rest, literal: word.literal })
|
|
467
|
+
return rest === ''
|
|
468
|
+
}
|
|
469
|
+
return false
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Reads the words after `curl`. A word made only of expansions where an option may stand could be any of them, so it
|
|
474
|
+
* counts as unknown; the value of an option (`-H "$AUTH"`) cannot.
|
|
475
|
+
*/
|
|
476
|
+
const readCurlCall = (args: Word[]): CurlCall => {
|
|
477
|
+
const call: CurlCall = { method: undefined, data: false, upload: false, get: false, override: false, unknown: false, targets: [] }
|
|
478
|
+
for (let index = 0; index < args.length; index++) {
|
|
479
|
+
const word = args[index]
|
|
480
|
+
if (!isOption(word)) {
|
|
481
|
+
if (isOpaque(word)) call.unknown = true
|
|
482
|
+
call.targets.push(word)
|
|
483
|
+
continue
|
|
484
|
+
}
|
|
485
|
+
if (!word.value.startsWith('--')) {
|
|
486
|
+
if (readShortGroup(call, word, args[index + 1])) index++
|
|
487
|
+
continue
|
|
488
|
+
}
|
|
489
|
+
const split = splitAttached(word)
|
|
490
|
+
const name = curlOptionName(split.name)
|
|
491
|
+
const takesNext = split.attached === undefined && CURL_LONG_WITH_VALUE.has(name)
|
|
492
|
+
readCurlOption(call, name, split.attached ?? (takesNext ? args[index + 1] : undefined))
|
|
493
|
+
if (takesNext) index++
|
|
494
|
+
}
|
|
495
|
+
return call
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const mentionsGitHubApi = (text: string): boolean => text.toLowerCase().includes(GITHUB_API_HOST)
|
|
499
|
+
|
|
500
|
+
/** A URL on the GitHub API, or one whose expanded part may be it and whose fixed end is a dangerous endpoint. */
|
|
501
|
+
const mayBeGitHubApi = (word: Word): boolean =>
|
|
502
|
+
mentionsGitHubApi(word.value) || (isExpanded(word.value) && isDangerousEndpoint(word))
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Whether `curl` writes to the GitHub API: a method other than GET or HEAD, named, set by an override header, or
|
|
506
|
+
* implied by an upload or by data that `-G` does not turn into a query; a config file or an unreadable word may add
|
|
507
|
+
* any of them.
|
|
508
|
+
*/
|
|
509
|
+
const curlWritesGitHub = (args: Word[]): boolean => {
|
|
510
|
+
const { method, data, upload, get, override, unknown, targets } = readCurlCall(args)
|
|
511
|
+
const implied = upload || (data && !get)
|
|
512
|
+
const writes = unknown || override || (method === undefined ? implied : isCurlWriteMethod(method))
|
|
513
|
+
return writes && targets.some(mayBeGitHubApi)
|
|
269
514
|
}
|
|
270
515
|
|
|
271
516
|
/** The words after the first plain `api` behind the program at `index`; all of them when `api` came from an expansion. */
|
|
@@ -275,9 +520,15 @@ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
|
|
|
275
520
|
return at === -1 ? after : after.slice(at + 1)
|
|
276
521
|
}
|
|
277
522
|
|
|
278
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
523
|
+
/**
|
|
524
|
+
* Every guarded command at a program position counts, except a `gh api` call that does not write dangerously and a
|
|
525
|
+
* `curl` that does not write to the GitHub API.
|
|
526
|
+
*/
|
|
527
|
+
const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean => {
|
|
528
|
+
if (command === API_COMMAND) return writesDangerously(apiArguments(words, programIndex))
|
|
529
|
+
if (command === CURL_COMMAND) return curlWritesGitHub(argumentsOf(words, programIndex))
|
|
530
|
+
return true
|
|
531
|
+
}
|
|
281
532
|
|
|
282
533
|
const commandWords = (command: string): string[] => command.split(' ').slice(1)
|
|
283
534
|
|
|
@@ -322,10 +573,61 @@ const argumentsOf = (words: SimpleCommand, index: number): Word[] => {
|
|
|
322
573
|
const namesCommandWords = (words: SimpleCommand, index: number, command: string): boolean =>
|
|
323
574
|
operandLists(argumentsOf(words, index)).some((operands) => matchesFrom(operands, commandWords(command)))
|
|
324
575
|
|
|
576
|
+
const isOtherServerOption = ({ value }: Word): boolean =>
|
|
577
|
+
OTHER_SERVER_OPTIONS.some((option) => value === option || value.startsWith(`${option}=`))
|
|
578
|
+
|
|
579
|
+
/** A word the shell hands on as written: quoted or plain, without an expansion, a glob or a brace list. */
|
|
580
|
+
const isFixedWord = ({ value, literal }: Word): boolean =>
|
|
581
|
+
!isExpanded(value) && !(literal && GLOB.test(value)) && !expandsBraces(value)
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The pane id of `herdr pane <verb> <pane id> …` written plainly: the first word of its command, the command words and
|
|
585
|
+
* the pane id unquoted, every word fixed, no option that points at another herdr server. Anything else names no pane
|
|
586
|
+
* the guard can look up.
|
|
587
|
+
*/
|
|
588
|
+
const plainPaneTarget = (words: SimpleCommand, index: number, command: string): string | undefined => {
|
|
589
|
+
const expected = command.split(' ')
|
|
590
|
+
const target = words[expected.length]
|
|
591
|
+
const plain =
|
|
592
|
+
index === 0 &&
|
|
593
|
+
words.every((word) => isFixedWord(word) && !isOtherServerOption(word)) &&
|
|
594
|
+
words.slice(0, expected.length + 1).every((word) => word.literal) &&
|
|
595
|
+
expected.every((value, at) => words[at]?.value === value)
|
|
596
|
+
return plain && target !== undefined && PANE_ID.test(target.value) ? target.value : undefined
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The target of `herdr agent <verb> <target> …` written plainly: the first word of its command, the command words and
|
|
601
|
+
* the target fixed and unquoted, no option that points at another herdr server. The keys or the prompt after it may
|
|
602
|
+
* be any text, since no shell runs them.
|
|
603
|
+
*/
|
|
604
|
+
const plainAgentTarget = (words: SimpleCommand, index: number, command: string): string | undefined => {
|
|
605
|
+
const expected = command.split(' ')
|
|
606
|
+
const target = words[expected.length]
|
|
607
|
+
const plain =
|
|
608
|
+
index === 0 &&
|
|
609
|
+
!words.some(isOtherServerOption) &&
|
|
610
|
+
words.slice(0, expected.length + 1).every((word) => word.literal && isFixedWord(word)) &&
|
|
611
|
+
expected.every((value, at) => words[at]?.value === value)
|
|
612
|
+
return plain && target !== undefined && AGENT_TARGET.test(target.value) ? target.value : undefined
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** The words `herdr pane <verb> <pane id>` writes into the pane, as one line; read only with a plain target. */
|
|
616
|
+
const paneInputOf = (words: SimpleCommand, command: string): string =>
|
|
617
|
+
words
|
|
618
|
+
.slice(command.split(' ').length + 1)
|
|
619
|
+
.map(({ value }) => value)
|
|
620
|
+
.join(' ')
|
|
621
|
+
|
|
325
622
|
/** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
|
|
326
623
|
const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
|
|
327
624
|
if (!namesCommandWords(words, index, command) || isReading(words, index, command)) return undefined
|
|
328
625
|
if (!isGuardedUse(command, words, index)) return undefined
|
|
626
|
+
if (PANE_INPUT_COMMANDS.includes(command)) {
|
|
627
|
+
const pane = plainPaneTarget(words, index, command)
|
|
628
|
+
return { command, merge: undefined, pane, input: pane === undefined ? undefined : paneInputOf(words, command) }
|
|
629
|
+
}
|
|
630
|
+
if (AGENT_INPUT_COMMANDS.includes(command)) return { command, merge: undefined, agent: plainAgentTarget(words, index, command) }
|
|
329
631
|
const plain = sole && words[index].literal && words[index].value === GH
|
|
330
632
|
return { command, sole: plain, merge: plain ? plainMerge(words.slice(index), commandWords(command)) : undefined }
|
|
331
633
|
}
|
|
@@ -336,7 +638,9 @@ const programsOf = (commands: readonly string[]): string[] => [
|
|
|
336
638
|
|
|
337
639
|
/** A shell alias or function at the word that may run one of the guarded programs: it hides the words after them. */
|
|
338
640
|
const definitionFinding = (words: SimpleCommand, index: number, { commands, definitions }: Search): Finding[] => {
|
|
339
|
-
|
|
641
|
+
// A definition hides the words after `curl`, so every alias around it would ask; the guard aims at an oversight (D108).
|
|
642
|
+
const programs = programsOf(commands).filter((program) => program !== CURL_COMMAND)
|
|
643
|
+
const definition = shellDefinitionAt(words, index, definitions, programs)
|
|
340
644
|
return definition ? [{ command: definition.name, merge: undefined, definition }] : []
|
|
341
645
|
}
|
|
342
646
|
|
|
@@ -346,6 +650,8 @@ const definitionFinding = (words: SimpleCommand, index: number, { commands, defi
|
|
|
346
650
|
*/
|
|
347
651
|
const expandedProgramFindings = (words: SimpleCommand, index: number, { commands }: Search): Finding[] =>
|
|
348
652
|
commands
|
|
653
|
+
// curl has no command words of its own, so any program from a variable could be it; only a written curl counts
|
|
654
|
+
.filter((command) => command !== CURL_COMMAND)
|
|
349
655
|
.filter((command) => namesCommandWords(words, index, command) && isGuardedUse(command, words, index))
|
|
350
656
|
.map((command) => ({ command, merge: undefined }))
|
|
351
657
|
|
|
@@ -432,7 +738,56 @@ const callsIn = (script: string, search: Search, top: boolean): Finding[] => {
|
|
|
432
738
|
return parsed.flatMap((words) => callsInCommand(words, search, sole))
|
|
433
739
|
}
|
|
434
740
|
|
|
435
|
-
|
|
741
|
+
/** Whether the policy calls this target safe; no target, no check or a throwing one answer no. */
|
|
742
|
+
const isSafeTarget = (target: string | undefined, isSafe: ((target: string) => boolean) | undefined): boolean => {
|
|
743
|
+
if (target === undefined || isSafe === undefined) return false
|
|
744
|
+
try {
|
|
745
|
+
return isSafe(target)
|
|
746
|
+
} catch {
|
|
747
|
+
return false
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const isPaneInput = ({ command, definition }: Finding): boolean =>
|
|
752
|
+
definition === undefined && PANE_INPUT_COMMANDS.includes(command)
|
|
753
|
+
|
|
754
|
+
const isAgentInput = ({ command, definition }: Finding): boolean =>
|
|
755
|
+
definition === undefined && AGENT_INPUT_COMMANDS.includes(command)
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The policy for these findings: with more than one input into a pane, none is lifted, since the lookup sees each
|
|
759
|
+
* pane before the script runs and an earlier input can start the herd a later one clicks into.
|
|
760
|
+
*/
|
|
761
|
+
const policyLiftingOnePaneInput = (findings: readonly Finding[], policy: GuardPolicy): GuardPolicy =>
|
|
762
|
+
findings.filter(isPaneInput).length > 1 ? { ...policy, isPaneSafe: undefined } : policy
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Whether the input into a pane without the herd asks itself: a shell there runs it where no hook sees it. Read as a
|
|
766
|
+
* script under a policy that lifts nothing, so a guarded command or another input into a pane asks; a herd word, an
|
|
767
|
+
* expansion or a text the parse cannot read asks too.
|
|
768
|
+
*/
|
|
769
|
+
const inputAsks = (input: string, policy: GuardPolicy): boolean => {
|
|
770
|
+
// A second line could run an alias the first one defined, and a quoted alias value is not searched.
|
|
771
|
+
if (input.includes('\n')) return true
|
|
772
|
+
try {
|
|
773
|
+
const words = parseShell(input).flat()
|
|
774
|
+
if (words.some(({ value }) => isExpanded(value) || HERD_WORDS.includes(value))) return true
|
|
775
|
+
} catch {
|
|
776
|
+
return true
|
|
777
|
+
}
|
|
778
|
+
const liftingNothing: GuardPolicy = {
|
|
779
|
+
commands: policy.commands,
|
|
780
|
+
isMergeApproved: () => false,
|
|
781
|
+
shellDefinitions: policy.shellDefinitions,
|
|
782
|
+
}
|
|
783
|
+
return decideBashCommand(input, liftingNothing).kind === 'ask'
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const approvalOf = (finding: Finding, policy: GuardPolicy): boolean | MergeNote => {
|
|
787
|
+
const { command, merge, pane, input = '' } = finding
|
|
788
|
+
if (isPaneInput(finding))
|
|
789
|
+
return command === LIFTABLE_PANE_INPUT && isSafeTarget(pane, policy.isPaneSafe) && !inputAsks(input, policy)
|
|
790
|
+
if (isAgentInput(finding)) return isSafeTarget(finding.agent, policy.isAgentSafe)
|
|
436
791
|
if (command !== MERGE_COMMAND || merge === undefined) return false
|
|
437
792
|
try {
|
|
438
793
|
return policy.isMergeApproved(merge)
|
|
@@ -461,8 +816,29 @@ const soleMergeSentence = (findings: Finding[]): string[] => {
|
|
|
461
816
|
return unchecked.length > 0 ? [PLAIN_MERGE_SENTENCE] : []
|
|
462
817
|
}
|
|
463
818
|
|
|
819
|
+
/** Why an input into a pane asks: the herd takes its clicks from its input. */
|
|
820
|
+
const paneInputSentence = (commands: string[]): string[] =>
|
|
821
|
+
commands.length === 0
|
|
822
|
+
? []
|
|
823
|
+
: [
|
|
824
|
+
`${commands.map((command) => `\`${command}\``).join(' and ')} writes input into a pane; the herd takes clicks from it, so the user confirms it. Only \`${LIFTABLE_PANE_INPUT}\` passes, once per call, into a plain pane id (\`${LIFTABLE_PANE_INPUT} w1:p2 …\` at the start of its command, no wrapper or assignment before it, every word fixed) whose foreground herdr names and which is not the herd, with a command that names no guarded command, no input into a pane and no herd. To end a lane's session, do not type into its pane: send \`herdr agent prompt <lane> /exit\` as a call of its own.`,
|
|
825
|
+
]
|
|
826
|
+
|
|
827
|
+
/** Why an input into an agent asks: a key there can answer the dialog the agent has open, as if the user clicked. */
|
|
828
|
+
const agentInputSentence = (commands: string[]): string[] =>
|
|
829
|
+
commands.length === 0
|
|
830
|
+
? []
|
|
831
|
+
: [
|
|
832
|
+
`${commands.map((command) => `\`${command}\``).join(' and ')} writes into an agent and can answer a dialog open there as if the user clicked, so the user confirms it. It passes only into a plain target (\`herdr agent prompt <lane> …\` at the start of its command, the target fixed) whose agent herdr names and which runs in a worktree, not in a main checkout, where the coordinator runs.`,
|
|
833
|
+
]
|
|
834
|
+
|
|
835
|
+
const CURL_SENTENCE = `A \`${CURL_COMMAND}\` that writes to the GitHub API can merge, commit or deploy past the merge guard, so the user confirms it. Merge with a plain \`${MERGE_COMMAND} <number>\`.`
|
|
836
|
+
|
|
464
837
|
const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
|
|
465
|
-
const
|
|
838
|
+
const named = findings.filter(({ definition }) => definition === undefined).map(({ command }) => command)
|
|
839
|
+
const guarded = named.filter((command) => !FIXED_COMMANDS.includes(command))
|
|
840
|
+
const paneInputs = named.filter((command) => PANE_INPUT_COMMANDS.includes(command))
|
|
841
|
+
const agentInputs = named.filter((command) => AGENT_INPUT_COMMANDS.includes(command))
|
|
466
842
|
const definitions = [
|
|
467
843
|
...new Map(
|
|
468
844
|
findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
|
|
@@ -472,6 +848,9 @@ const askBecause = (findings: Finding[], notes: string[], script: string): Merge
|
|
|
472
848
|
kind: 'ask',
|
|
473
849
|
reason: [
|
|
474
850
|
...guardedListSentence([...new Set(guarded)]),
|
|
851
|
+
...paneInputSentence([...new Set(paneInputs)]),
|
|
852
|
+
...agentInputSentence([...new Set(agentInputs)]),
|
|
853
|
+
...(named.includes(CURL_COMMAND) ? [CURL_SENTENCE] : []),
|
|
475
854
|
...definitions.map(
|
|
476
855
|
(definition) =>
|
|
477
856
|
`${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
|
|
@@ -542,19 +921,28 @@ const findingsOrUnreadable = (command: string, search: Search): Finding[] | Shel
|
|
|
542
921
|
* shell's input, a here-document's data and what `xargs` reads are not searched, and a call that only reads (`--help`,
|
|
543
922
|
* `-h`, `gh … view`, `gh … list`) passes. A script that is exactly `gh pr merge <number>` with merge-method and
|
|
544
923
|
* delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
|
|
545
|
-
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks.
|
|
546
|
-
*
|
|
924
|
+
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. The inputs into a pane
|
|
925
|
+
* (`PANE_INPUT_COMMANDS`) are guarded whatever the list says, since the herd takes clicks from its input: a plain
|
|
926
|
+
* `herdr pane <verb> <pane id> …` passes when `isPaneSafe` says the pane cannot be the herd, every other shape asks.
|
|
927
|
+
* The inputs into an agent (`AGENT_INPUT_COMMANDS`) are guarded the same way: a plain `herdr agent <verb> <target> …`
|
|
928
|
+
* passes when `isAgentSafe` says the agent is not the coordinator. `curl` is guarded whatever the list says and asks
|
|
929
|
+
* only for a write to the GitHub API (a write method, a body, an upload or a config file); `gh api graphql` asks for
|
|
930
|
+
* a dangerous mutation (`DANGEROUS_MUTATIONS`) and for a query whose text the command does not show.
|
|
931
|
+
* A script the parse cannot read asks only when a guarded program stands where a command starts.
|
|
547
932
|
*/
|
|
548
933
|
export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
|
|
549
|
-
|
|
550
|
-
const search = { commands
|
|
934
|
+
const commands = [...new Set([...policy.commands, ...FIXED_COMMANDS])]
|
|
935
|
+
const search = { commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
|
|
551
936
|
const findings = findingsOrUnreadable(command, search)
|
|
552
937
|
if (findings instanceof ShellParseError) {
|
|
553
|
-
|
|
938
|
+
// unreadable text with a curl asks only where it names the GitHub API: a curl elsewhere is no merge
|
|
939
|
+
const searched = mentionsGitHubApi(command) ? commands : commands.filter((each) => each !== CURL_COMMAND)
|
|
940
|
+
return namesGuardedProgram(command, searched) ? askUnreadable(findings, command) : PASS
|
|
554
941
|
}
|
|
555
942
|
if (findings.length === 0) return PASS
|
|
943
|
+
const findingsPolicy = policyLiftingOnePaneInput(findings, policy)
|
|
556
944
|
const unapproved = findings
|
|
557
|
-
.map((finding) => ({ finding, approval: approvalOf(finding,
|
|
945
|
+
.map((finding) => ({ finding, approval: approvalOf(finding, findingsPolicy) }))
|
|
558
946
|
.filter(({ approval }) => approval !== true)
|
|
559
947
|
if (unapproved.length === 0) return PASS
|
|
560
948
|
const notes = unapproved.flatMap(({ approval }) => (typeof approval === 'object' ? [approval.note] : []))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input into a herdr pane. The herd takes its clicks as mouse reports on its own input, and these three commands
|
|
3
|
+
* write into a pane's input, so an agent could type a click into the herd with them. `herdr agent send-keys` and
|
|
4
|
+
* `herdr agent prompt` reach only a pane with an agent (`agent_not_found` otherwise, herdr 0.9.0), never the herd; but
|
|
5
|
+
* a key there answers the dialog open in that agent, and the transcript records it as the user's click (tried in a
|
|
6
|
+
* throwaway session), so into the coordinator they ask.
|
|
7
|
+
*/
|
|
8
|
+
import { isRecord } from '../shared/json'
|
|
9
|
+
|
|
10
|
+
/** The commands that write into a pane's input; fixed in the guard, never taken from `guard.commands`. */
|
|
11
|
+
export const PANE_INPUT_COMMANDS: readonly string[] = ['herdr pane send-text', 'herdr pane send-keys', 'herdr pane run']
|
|
12
|
+
|
|
13
|
+
/** The commands that write into an agent's input; fixed in the guard like the pane inputs. */
|
|
14
|
+
export const AGENT_INPUT_COMMANDS: readonly string[] = ['herdr agent send-keys', 'herdr agent prompt']
|
|
15
|
+
|
|
16
|
+
/** An agent target as herdr takes it: a pane id or an agent name. */
|
|
17
|
+
export const AGENT_TARGET = /^[A-Za-z0-9][A-Za-z0-9:_.-]{0,63}$/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The one input into a pane that can pass without a question, a whole command line at a time. Text and keys always
|
|
21
|
+
* ask: the pane's shell keeps a line typed in one call for the next, which no check of a single call sees.
|
|
22
|
+
*/
|
|
23
|
+
export const LIFTABLE_PANE_INPUT = 'herdr pane run'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The words that start the herd after the CLI (`jh herd`, and `plans` as it was called up to 1.6), as
|
|
27
|
+
* `src/plans/herd-command.ts` names them; a contract test holds the two together.
|
|
28
|
+
*/
|
|
29
|
+
export const HERD_WORDS: readonly string[] = ['herd', 'plans']
|
|
30
|
+
|
|
31
|
+
/** A pane id as herdr writes it, `w3B:p1`; a pane name is no target of these commands. */
|
|
32
|
+
export const PANE_ID = /^[A-Za-z0-9]+:p[A-Za-z0-9]+$/
|
|
33
|
+
|
|
34
|
+
/** Options that point the command at another herdr server, where the lookup would not find the pane. */
|
|
35
|
+
export const OTHER_SERVER_OPTIONS: readonly string[] = ['--session', '--remote']
|
|
36
|
+
|
|
37
|
+
const isArgv = (value: unknown): value is string[] =>
|
|
38
|
+
Array.isArray(value) && value.every((word) => typeof word === 'string')
|
|
39
|
+
|
|
40
|
+
/** The argv of each foreground process in a `herdr pane process-info` answer; undefined for an answer of another shape. */
|
|
41
|
+
export const foregroundOf = (json: string): string[][] | undefined => {
|
|
42
|
+
try {
|
|
43
|
+
const answer: unknown = JSON.parse(json)
|
|
44
|
+
const info = isRecord(answer) && isRecord(answer.result) ? answer.result.process_info : undefined
|
|
45
|
+
const processes = isRecord(info) ? info.foreground_processes : undefined
|
|
46
|
+
if (!Array.isArray(processes)) return undefined
|
|
47
|
+
return processes.flatMap((each) => (isRecord(each) && isArgv(each.argv) ? [each.argv] : []))
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a pane with these foreground processes cannot be the herd: herdr named at least one, and none carries a
|
|
55
|
+
* herd word after its program. Any doubt (no answer, no process named) counts as the herd. A word `herd` in another
|
|
56
|
+
* program's arguments asks once too often, never once too few.
|
|
57
|
+
*/
|
|
58
|
+
export const cannotBeHerd = (foreground: readonly (readonly string[])[] | undefined): boolean =>
|
|
59
|
+
foreground !== undefined &&
|
|
60
|
+
foreground.length > 0 &&
|
|
61
|
+
foreground.every((argv) => !argv.slice(1).some((word) => HERD_WORDS.includes(word)))
|