autonomous-sdlc-harness 0.1.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/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- package/dist/cli.js +194 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/config.js +561 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/daemon.js +791 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/doctor.js +336 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.js +2023 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/registry.js +42 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/config/check.js +505 -0
- package/dist/config/check.js.map +1 -0
- package/dist/config/io.js +177 -0
- package/dist/config/io.js.map +1 -0
- package/dist/config/model.js +406 -0
- package/dist/config/model.js.map +1 -0
- package/dist/core/errors.js +71 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git.js +537 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/json.js +125 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/layerCoverage.js +141 -0
- package/dist/core/layerCoverage.js.map +1 -0
- package/dist/core/layerGapRemedy.js +62 -0
- package/dist/core/layerGapRemedy.js.map +1 -0
- package/dist/core/nameList.js +23 -0
- package/dist/core/nameList.js.map +1 -0
- package/dist/core/paths.js +153 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/prompt.js +206 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/repoPaths.js +55 -0
- package/dist/core/repoPaths.js.map +1 -0
- package/dist/core/report.js +150 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/templating.js +88 -0
- package/dist/core/templating.js.map +1 -0
- package/dist/core/writer.js +479 -0
- package/dist/core/writer.js.map +1 -0
- package/dist/daemon/backend.js +180 -0
- package/dist/daemon/backend.js.map +1 -0
- package/dist/daemon/units.js +380 -0
- package/dist/daemon/units.js.map +1 -0
- package/dist/detect/nestedApplication.js +79 -0
- package/dist/detect/nestedApplication.js.map +1 -0
- package/dist/detect/presets.js +2033 -0
- package/dist/detect/presets.js.map +1 -0
- package/dist/detect/signals.js +1368 -0
- package/dist/detect/signals.js.map +1 -0
- package/dist/doctor/checks.js +3530 -0
- package/dist/doctor/checks.js.map +1 -0
- package/dist/generators/claudeContext.js +588 -0
- package/dist/generators/claudeContext.js.map +1 -0
- package/dist/generators/githooks.js +446 -0
- package/dist/generators/githooks.js.map +1 -0
- package/dist/generators/harnessConfig.js +632 -0
- package/dist/generators/harnessConfig.js.map +1 -0
- package/dist/generators/notifications.js +191 -0
- package/dist/generators/notifications.js.map +1 -0
- package/dist/generators/outerLoopScripts.js +165 -0
- package/dist/generators/outerLoopScripts.js.map +1 -0
- package/dist/generators/permissionProfile.js +1172 -0
- package/dist/generators/permissionProfile.js.map +1 -0
- package/dist/generators/projectSettings.js +322 -0
- package/dist/generators/projectSettings.js.map +1 -0
- package/dist/generators/repoRoot.js +417 -0
- package/dist/generators/repoRoot.js.map +1 -0
- package/dist/generators/scripts.js +557 -0
- package/dist/generators/scripts.js.map +1 -0
- package/dist/generators/stateDir.js +221 -0
- package/dist/generators/stateDir.js.map +1 -0
- package/dist/machine/paths.js +111 -0
- package/dist/machine/paths.js.map +1 -0
- package/dist/machine/plugins.js +224 -0
- package/dist/machine/plugins.js.map +1 -0
- package/dist/machine/registry.js +330 -0
- package/dist/machine/registry.js.map +1 -0
- package/package.json +23 -0
- package/scripts/README.md +13 -0
- package/scripts/daemon/launchd.plist.template +59 -0
- package/scripts/daemon/systemd.service.template +58 -0
- package/templates/README.md +15 -0
- package/templates/claude/CLAUDE.md +54 -0
- package/templates/claude/README.md +5 -0
- package/templates/claude/context/api.md +29 -0
- package/templates/claude/context/conventions.md +23 -0
- package/templates/claude/context/data-layer.md +28 -0
- package/templates/claude/context/data-storage.md +29 -0
- package/templates/claude/context/docs-catalog.md +29 -0
- package/templates/claude/context/domain.md +28 -0
- package/templates/claude/context/layer.md +20 -0
- package/templates/claude/context/module.md +30 -0
- package/templates/claude/context/package.md +29 -0
- package/templates/claude/context/presentation.md +32 -0
- package/templates/claude/context/state-slices.md +28 -0
- package/templates/claude/context/tests.md +28 -0
- package/templates/claude/harness-task-offer.md +58 -0
- package/templates/claude/push-notify.env.example +21 -0
- package/templates/claude/qa-accounts.env.example +38 -0
- package/templates/claude/qa_test_scenarios.md +110 -0
- package/templates/claude/settings.autonomous.json +93 -0
- package/templates/claude/settings.autonomous.qa.json +36 -0
- package/templates/githooks/README.md +3 -0
- package/templates/githooks/pre-push +72 -0
- package/templates/repo/README.md +3 -0
- package/templates/repo/gitattributes +16 -0
- package/templates/repo/gitignore +61 -0
- package/templates/repo/gitignore.qa +25 -0
- package/templates/repo/mcp.json +17 -0
- package/templates/scripts/README.md +5 -0
- package/templates/scripts/autonomous-format-stream.sh +95 -0
- package/templates/scripts/autonomous-notify.sh +337 -0
- package/templates/scripts/autonomous-watcher.sh +3087 -0
- package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
- package/templates/scripts/commit-on-branch.sh +288 -0
- package/templates/scripts/create-worktree.sh +360 -0
- package/templates/scripts/deploy.sh +47 -0
- package/templates/scripts/lib/harness-run-lib.sh +1481 -0
- package/templates/scripts/push-branch.sh +140 -0
- package/templates/scripts/refresh-branch.sh +244 -0
- package/templates/scripts/restart-watcher.sh +401 -0
- package/templates/scripts/scratch-run.sh +302 -0
- package/templates/scripts/setup-worktree.sh +262 -0
- package/templates/scripts/start-dev-server.sh +99 -0
- package/templates/scripts/test.sh +50 -0
- package/templates/scripts/typecheck.sh +50 -0
- package/templates/state-dir/README-root.md +13 -0
- package/templates/state-dir/README.md +9 -0
- package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
- package/templates/state-dir/architecture_reviews/README.md +9 -0
- package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
- package/templates/state-dir/autonomous_inbox/README.md +9 -0
- package/templates/state-dir/autonomous_logs/README.md +9 -0
- package/templates/state-dir/branch_statistics/README.md +9 -0
- package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
- package/templates/state-dir/clarification_digests/README.md +9 -0
- package/templates/state-dir/clarifications/README.md +9 -0
- package/templates/state-dir/code_reviews/README.md +9 -0
- package/templates/state-dir/dispatch_additions/README.md +19 -0
- package/templates/state-dir/docs_catalog/README.md +9 -0
- package/templates/state-dir/flow_progress/README.md +9 -0
- package/templates/state-dir/improvement_observations/README.md +19 -0
- package/templates/state-dir/improvement_suggestions.md +29 -0
- package/templates/state-dir/lessons.md +23 -0
- package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
- package/templates/state-dir/qa_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_reviews/README.md +9 -0
- package/templates/state-dir/scratch/README.md +11 -0
- package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_reviews/README.md +9 -0
- package/templates/state-dir/story_plans/README.md +9 -0
- package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/task_plan_reviews/README.md +9 -0
- package/templates/state-dir/task_plans/README.md +9 -0
- package/templates/state-dir/task_prompts/README.md +9 -0
- package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
- package/templates/state-dir/ui_test_plans/README.md +9 -0
- package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/user_reviews/README.md +9 -0
|
@@ -0,0 +1,1172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generator: the unattended-run permission profile — the renderer, and the write that lands it.
|
|
3
|
+
*
|
|
4
|
+
* **The rule this module exists to enforce: the generator must be incapable of producing an entry
|
|
5
|
+
* that stalls an unattended run.** In print mode a tool call that matches neither `allow` nor
|
|
6
|
+
* `deny` does not prompt and does not fail — it hangs, and the run parks with no diagnostic. Every
|
|
7
|
+
* shape below is the way it is because some run already paid for the alternative, and each of those
|
|
8
|
+
* three prices is stated once here and once in the template's own `_README`:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Wrapper paths are unquoted literals.** The allow-list guard matches the raw command string,
|
|
11
|
+
* so a quoted path fails a `*.sh` match, falls through to a prompt and stalls.
|
|
12
|
+
* 2. **One command per entry, never a compound.** A single `mkdir && cp && rm` block is refused
|
|
13
|
+
* where each of those commands succeeds on its own, because the joined string matches no entry.
|
|
14
|
+
* 3. **The checkout *and* its sibling worktrees.** A glob that misses the worktree a run is
|
|
15
|
+
* executing in does not fail loudly; it silently skips whatever phase needed the path. A
|
|
16
|
+
* single-checkout adopter simply has no sibling for the second entry to match, which is why the
|
|
17
|
+
* worktree entries are unconditional rather than a second template.
|
|
18
|
+
*
|
|
19
|
+
* {@link assertRunnable} turns 1 and 2 from prose into a check: an entry carrying a quote or a
|
|
20
|
+
* shell operator throws {@link EXIT.INTERNAL} before the profile can be written. That code is
|
|
21
|
+
* deliberate, and it is why {@link assertRepositoryValuesUsable} screens the repository-side values
|
|
22
|
+
* every entry is built from — the checkout path, `projectName`, `scriptsDir`, `stateDir` — against
|
|
23
|
+
* the same shapes **first**, with the ordinary exit code and a message naming the value to change.
|
|
24
|
+
* A checkout under a directory carrying `&` is the adopter's to fix in one rename; only what
|
|
25
|
+
* survives that screen is a fault in this CLI or in its shipped template.
|
|
26
|
+
*
|
|
27
|
+
* ## Three non-obvious choices, and where each comes from
|
|
28
|
+
*
|
|
29
|
+
* 1. **Every wrapper is allow-listed in three forms, not one.** `commands.*` is repo-relative —
|
|
30
|
+
* every path in `harness.config.json` is (`docs/config.md` §5) — while a profile entry is
|
|
31
|
+
* conventionally absolute. A profile carrying only the absolute form would leave the configured
|
|
32
|
+
* verification command matching neither `allow` nor `deny`. So each selected wrapper contributes
|
|
33
|
+
* the repo-relative invocation an agent runs verbatim from the repository root, its
|
|
34
|
+
* repo-root-absolute twin for a caller that resolves the path first, and its sibling-worktree
|
|
35
|
+
* twin for a run executing in a second working copy. The agent-invocable outer-loop scripts land
|
|
36
|
+
* in the same directory and are emitted through the same row family, so they take the same three
|
|
37
|
+
* forms for the same reason rather than a shape of their own.
|
|
38
|
+
* 2. **The wrapper set comes from {@link WRAPPER_SCRIPTS} and the invocation string from
|
|
39
|
+
* {@link wrapperInvocation}** — never from parsing a `commands.*` value back into a path. That
|
|
40
|
+
* value may legitimately be a raw command line an adopter wrote by hand, and parsing it is how
|
|
41
|
+
* the config, the wrapper files and this profile drift apart. Form (i) is
|
|
42
|
+
* {@link wrapperInvocation}'s own return value, so it is byte-identical to the string the config
|
|
43
|
+
* holds by construction rather than by inspection.
|
|
44
|
+
* 3. **The template is parsed first and substituted second.** Substituting into the raw text and
|
|
45
|
+
* parsing afterwards would let a value containing a character JSON escapes — a backslash in a
|
|
46
|
+
* Windows-shaped path, a quote in a project name — produce a document that no longer parses, or
|
|
47
|
+
* worse, one that parses into something else. Parsing first means a substituted value can only
|
|
48
|
+
* ever be a string *inside* a JSON string.
|
|
49
|
+
*
|
|
50
|
+
* ## The browser half is a fragment, not a second profile
|
|
51
|
+
*
|
|
52
|
+
* The interactive-test phase's wiring lives in {@link QA_TEMPLATE_PATH} and is merged into the base
|
|
53
|
+
* when — and only when — `phases.qa` is on **and** `qa.driver` is `web-playwright`
|
|
54
|
+
* (`config/model.ts`'s `browserWiringApplies`, which the repository's own `.mcp.json` generator
|
|
55
|
+
* reads too). An adopter who never drives a browser should pay neither the browser tool schemas in
|
|
56
|
+
* every session's context nor a launched browser process, and that is as true of a mobile-driver
|
|
57
|
+
* adopter with the phase on as of one with the phase off: the two mobile variants of the interactive
|
|
58
|
+
* test agent ship declared-not-implemented with built-ins-only tool allowlists, so a fragment
|
|
59
|
+
* written for them would start servers no agent can call. It is a **partial** profile rather than a
|
|
60
|
+
* second full one: a second full file would have to be kept in step with the base by hand, and the
|
|
61
|
+
* halves would drift the first time an entry was added to one of them. The fragment therefore
|
|
62
|
+
* declares only what the phase adds, and {@link mergeInto} refuses any key the base already sets —
|
|
63
|
+
* one producer per entry, the same rule the wrapper table follows.
|
|
64
|
+
*
|
|
65
|
+
* Its two keys do different jobs and both are needed: `enabledMcpjsonServers` starts the servers the
|
|
66
|
+
* repository's MCP wiring declares, and the `mcp__…` `allow` entries gate which of their tools may
|
|
67
|
+
* run. Without the enablement the tools do not exist at run time, and an un-loaded tool stalls a
|
|
68
|
+
* print-mode run rather than failing it — which is why {@link assertBrowserWiring} checks the two
|
|
69
|
+
* against each other in both directions.
|
|
70
|
+
*
|
|
71
|
+
* ## The reference toolchain, and the plugin key that stays reserved
|
|
72
|
+
*
|
|
73
|
+
* When the parity phase is on, the reference implementation's own toolchain usually lives outside
|
|
74
|
+
* the checkout, so its commands match no path-scoped entry above. `init --reference-toolchain-path`
|
|
75
|
+
* supplies that directory and each `parity.toolchainCommands` value becomes one literal entry under
|
|
76
|
+
* it. The path is machine-local, which is the case the plugin's `userConfig` mechanism exists for —
|
|
77
|
+
* but a `userConfig` option is prompted at enable time, and the plugin key of the same name stays
|
|
78
|
+
* **reserved** until the parity module ships the agent-side reader that consumes it. Until then the
|
|
79
|
+
* CLI takes the path from the flag, and nothing prompts an adopter for a value nothing reads.
|
|
80
|
+
*
|
|
81
|
+
* ## What this module deliberately does not do
|
|
82
|
+
*
|
|
83
|
+
* - **It touches no filesystem beyond reading its two templates — and, on a forced run, the profile
|
|
84
|
+
* it is about to replace.** {@link writePermissionProfile} enqueues the rendered profile into the
|
|
85
|
+
* command's write plan under the `create-if-absent` contract — the profile is hand-tuned after
|
|
86
|
+
* generation, and an overwrite silently drops the allow entries an adopter added to close a stall —
|
|
87
|
+
* and the write itself belongs to the write engine. `--force` is the one flag that suspends that
|
|
88
|
+
* contract, and therefore the one run under which those hand-added entries can be lost, so
|
|
89
|
+
* {@link carriedPluginRootEntries} reads the profile back and {@link renderProfile} carries forward
|
|
90
|
+
* every entry naming a plugin root this machine resolves (`machine/plugins.ts`, which reads the
|
|
91
|
+
* agent runner's own records) — and, where **no** root resolves at all, every absolute entry
|
|
92
|
+
* outside this checkout, unverified: a run that graded nothing revokes nothing. That is a read and
|
|
93
|
+
* not a question put to the adopter again: they
|
|
94
|
+
* were told once, by `doctor`, exactly which lines to paste. Preserving an entry is not generating
|
|
95
|
+
* one — the open owner decision below is untouched by it.
|
|
96
|
+
* - **It adds no `hooks` key.** The plugin ships its own guard hooks, which fire from the plugin,
|
|
97
|
+
* resolve their own root and append to the adopter's hooks rather than replacing them; writing
|
|
98
|
+
* them into user settings as well would run each guard twice. The template says so in a comment
|
|
99
|
+
* field, so the omission reads as a decision rather than as something to fix.
|
|
100
|
+
* - **It adds no browser-namespace `deny`.** A deny is evaluated before any allow and cannot be
|
|
101
|
+
* overridden, so it would revoke the interactive-test agent's own grant. That closure is made per
|
|
102
|
+
* agent, by each agent definition's `tools:` allowlist.
|
|
103
|
+
* - **It does not allow-list the interactive-test phase's own helper scripts.** Those helpers ship
|
|
104
|
+
* inside the plugin and are invoked `bash ${CLAUDE_PLUGIN_ROOT}/scripts/<name>.sh`. An entry that
|
|
105
|
+
* keeps the token buys nothing: the guard matches the raw command string, so a substitution
|
|
106
|
+
* anywhere in it defeats the match however the entry is worded (measured on `claude` 2.1.227; the
|
|
107
|
+
* runs, and the literal-path control that makes them attributable, are in `docs/development.md`
|
|
108
|
+
* §3, which also records that a directory-prefix entry misses even with a literal path). An entry
|
|
109
|
+
* naming a **resolved** root does match, and is measured to reach every one of these helpers
|
|
110
|
+
* whatever kind of file calls them (`plugin/scripts/README.md`) — so what keeps the entry out of
|
|
111
|
+
* this generator is not an unknowable root: the agent runner's own `installed_plugins.json` records one per plugin.
|
|
112
|
+
* It is that `init` may run **before** the plugin is enabled, leaving no root to read, and that
|
|
113
|
+
* the root carries the plugin **version**, so an entry written once goes silently stale on an
|
|
114
|
+
* upgrade. The resolving is therefore done at check time rather than here — `doctor`'s
|
|
115
|
+
* `plugin-permissions` check re-derives the root on every run and prints each missing line — and
|
|
116
|
+
* {@link QA_TEMPLATE_PATH}'s `_README` carries the entry *form* an adopter adds by hand, which the
|
|
117
|
+
* `create-if-absent` contract above — and, on the run that suspends it, the carry-forward named
|
|
118
|
+
* there — is what makes survive every later `init`. Whether `init`
|
|
119
|
+
* should also *write* those entries is an open owner decision rather than a closed one; nothing
|
|
120
|
+
* here takes it. Moving the helpers somewhere a rule can name unconditionally — the adopter's own
|
|
121
|
+
* `scriptsDir`, which the three wrapper forms already reach — would remove the manual step but
|
|
122
|
+
* change the invocation form everywhere the shipped instruction corpus calls them, so it belongs
|
|
123
|
+
* to the item that ships them (`plugin/scripts/README.md`) and not to this module.
|
|
124
|
+
* - **It emits no entry for an outer-loop script the table does not mark agent-invocable.** See
|
|
125
|
+
* below: that omission is the decision, not a gap.
|
|
126
|
+
*
|
|
127
|
+
* ## The outer-loop scripts, and which of them reach this file
|
|
128
|
+
*
|
|
129
|
+
* `init` writes a **second** family of scripts into the same `scriptsDir` the wrappers land in — the
|
|
130
|
+
* run watcher, the git wrappers, the worktree tooling and the library they source. Their destination
|
|
131
|
+
* is settled: they are written into the adopting repository, which is the form the shipped
|
|
132
|
+
* instruction corpus already invokes them in, so the entry each one needs is an added row here
|
|
133
|
+
* rather than a new mechanism.
|
|
134
|
+
*
|
|
135
|
+
* Only the rows {@link OUTER_LOOP_SCRIPTS} marks {@link OuterLoopScript.agentInvocable} get entries,
|
|
136
|
+
* and each gets the same three forms a wrapper does, built by the same row family from the same
|
|
137
|
+
* {@link scriptInvocation} return value. The set is taken from {@link writeOuterLoopScripts}'s own
|
|
138
|
+
* `written` result when `init` supplies it — exactly as the wrapper set prefers `written` over
|
|
139
|
+
* {@link selectWrapperScripts} — so this file cannot name a script that generator did not write.
|
|
140
|
+
*
|
|
141
|
+
* Two properties of those entries are load-bearing:
|
|
142
|
+
*
|
|
143
|
+
* - **They are `allow`, never `ask`.** The corpus invokes them on the unattended commit path, and
|
|
144
|
+
* `ask` is evaluated before `allow`, so an `ask` there is precisely the stall this module exists to
|
|
145
|
+
* prevent. The deploy wrapper's `ask` treatment is unaffected — it is a wrapper row, and a deploy
|
|
146
|
+
* is a decision a person makes.
|
|
147
|
+
* - **Every other row gets nothing, deliberately.** The shared library is only ever sourced, and
|
|
148
|
+
* every remaining row — the watcher, the worktree and cleanup scripts, the notification helpers —
|
|
149
|
+
* is run by the watcher process or by a person. An entry for one of those would hand a dispatched
|
|
150
|
+
* agent a path to a branch deletion or a daemon restart, and the table's flag is where that
|
|
151
|
+
* judgement is recorded.
|
|
152
|
+
*
|
|
153
|
+
* A **second, independent** mechanism covers some of the same commands from the plugin side: the
|
|
154
|
+
* script-allowlist guard auto-allows a Bash command when every script it runs resolves under the
|
|
155
|
+
* configured scripts directory (or the same repo-relative path in a sibling worktree), no basename
|
|
156
|
+
* is on its deny list, **and** nothing anywhere in the command string carries `$(…)`, a backtick,
|
|
157
|
+
* `|`, `<`, a braced expansion other than a bare `${IDENT}`, or a `>` that is neither a descriptor
|
|
158
|
+
* duplication nor a redirection to the literal `/dev/null` — that whole-string construct scan
|
|
159
|
+
* withholds the allow wherever such a byte sits, a wrapper's own arguments included, and a `.sh`
|
|
160
|
+
* token carrying a `$` is refused outright.
|
|
161
|
+
*
|
|
162
|
+
* The two are belt-and-braces rather than duplicates, and that bound is the second reason why. The
|
|
163
|
+
* guard needs the plugin enabled and its configuration resolvable where a row in this file needs
|
|
164
|
+
* neither; and the guard withholds on properties of the *command* — the constructs above — that
|
|
165
|
+
* these rows, written as literal invocation prefixes, do not range over at all. Neither mechanism
|
|
166
|
+
* is derivable from the other, so removing either narrows what an unattended run can spell.
|
|
167
|
+
*/
|
|
168
|
+
import { basename, dirname, isAbsolute, join, sep } from 'node:path';
|
|
169
|
+
import { browserWiringApplies, CONFIG_FILENAME, DEFAULTS } from '../config/model.js';
|
|
170
|
+
import { HarnessError, internal } from '../core/errors.js';
|
|
171
|
+
import { isJsonObject, readJsonFile } from '../core/json.js';
|
|
172
|
+
import { defaultProjectName, readTemplate, workRoot as resolveWorkRoot, worktreeGlob } from '../core/paths.js';
|
|
173
|
+
import { normalizeRepoDir } from '../core/repoPaths.js';
|
|
174
|
+
import { containsToken, renderTemplate } from '../core/templating.js';
|
|
175
|
+
import { installedPluginsPath, pluginInstallRoot, pluginRuntimeRoot, SCRIPTS_DIRNAME } from '../machine/plugins.js';
|
|
176
|
+
import { OUTER_LOOP_SCRIPTS, outerLoopRelativePath, } from './outerLoopScripts.js';
|
|
177
|
+
import { scriptInvocation, selectWrapper, WRAPPER_SCRIPTS, wrapperInvocation, } from './scripts.js';
|
|
178
|
+
/**
|
|
179
|
+
* The template's home under `cli/templates/`, addressed as {@link readTemplate} wants it.
|
|
180
|
+
*
|
|
181
|
+
* Exported for the same reason {@link QA_TEMPLATE_PATH} is: the shipped template is the single
|
|
182
|
+
* declaration of the profile's own floor, and `doctor` compares an adopter's `permissions.deny`
|
|
183
|
+
* against it rather than keeping a second copy of the entries that must be there
|
|
184
|
+
* (`doctor/checks.ts`).
|
|
185
|
+
*/
|
|
186
|
+
export const TEMPLATE_PATH = 'claude/settings.autonomous.json';
|
|
187
|
+
/**
|
|
188
|
+
* The interactive-test fragment, merged into the base only when the phase is on and its driver is
|
|
189
|
+
* the browser one — see the module header.
|
|
190
|
+
*
|
|
191
|
+
* A **partial** profile — see the module header. It is stored beside the base under the same
|
|
192
|
+
* dot-less directory, and neither file is ever named with the adopter's leading dot inside this
|
|
193
|
+
* repository (`cli/templates/claude/README.md`).
|
|
194
|
+
*
|
|
195
|
+
* Exported because the fragment's `enabledMcpjsonServers` list is the single declaration of which
|
|
196
|
+
* browser servers a run starts, and the generator that writes the repository's own MCP wiring
|
|
197
|
+
* checks its declared servers against it rather than keeping a second copy of the names
|
|
198
|
+
* (`generators/repoRoot.ts`).
|
|
199
|
+
*/
|
|
200
|
+
export const QA_TEMPLATE_PATH = 'claude/settings.autonomous.qa.json';
|
|
201
|
+
/** The adopter-side directory the rendered profile lands in. The dot is added here, at `init` time. */
|
|
202
|
+
const CLAUDE_DIR = '.claude';
|
|
203
|
+
/**
|
|
204
|
+
* Where the rendered profile is written, relative to the adopting repository's root — and the path
|
|
205
|
+
* an unattended run names in its own `--settings` flag.
|
|
206
|
+
*
|
|
207
|
+
* Exported because more than one caller has to say it: `init`'s summary points at it, and `doctor`
|
|
208
|
+
* checks that the file is there and that the absolute paths inside it still resolve.
|
|
209
|
+
*/
|
|
210
|
+
export const PROFILE_PATH = `${CLAUDE_DIR}/settings.autonomous.json`;
|
|
211
|
+
/** The profile's rationale block: the array every generated explanation is appended to. */
|
|
212
|
+
const README_KEY = '_README';
|
|
213
|
+
/** The key that starts the repository's declared MCP servers for the run. Absent unless QA is on. */
|
|
214
|
+
const ENABLED_SERVERS_KEY = 'enabledMcpjsonServers';
|
|
215
|
+
/** The prefix every MCP tool entry carries, and the pattern its server name is read out of. */
|
|
216
|
+
const MCP_ENTRY_PREFIX = 'mcp__';
|
|
217
|
+
const MCP_SERVER_PATTERN = /^mcp__(.+?)__/;
|
|
218
|
+
/** The interpreter every wrapper invocation starts with, per {@link wrapperInvocation}. */
|
|
219
|
+
const INVOCATION_PREFIX = 'bash ';
|
|
220
|
+
/**
|
|
221
|
+
* Per-wrapper tokens: a template entry carrying one of these is a **form row**, emitted once per
|
|
222
|
+
* selected wrapper script rather than once outright.
|
|
223
|
+
*/
|
|
224
|
+
const WRAPPER_ROW_TOKENS = Object.freeze({
|
|
225
|
+
invocation: 'scriptInvocation',
|
|
226
|
+
file: 'scriptFile',
|
|
227
|
+
path: 'scriptPath',
|
|
228
|
+
});
|
|
229
|
+
/**
|
|
230
|
+
* The same three forms, for the outer-loop rows a dispatched agent is the thing that runs.
|
|
231
|
+
*
|
|
232
|
+
* A separate token family rather than more values on the wrapper one, because the two sets are
|
|
233
|
+
* selected by different rules and are answered for by different generators: a wrapper exists only
|
|
234
|
+
* for a command that resolved, while an outer-loop script is written unconditionally and reaches
|
|
235
|
+
* this file only if its row says an agent may run it. Keeping them apart is also what lets the
|
|
236
|
+
* template put the two groups where a reader expects them — a run of rows per family — instead of
|
|
237
|
+
* interleaving them.
|
|
238
|
+
*
|
|
239
|
+
* These rows live in `allow` and never in `ask`: see the module header.
|
|
240
|
+
*/
|
|
241
|
+
const OUTER_LOOP_ROW_TOKENS = Object.freeze({
|
|
242
|
+
invocation: 'outerLoopInvocation',
|
|
243
|
+
file: 'outerLoopFile',
|
|
244
|
+
path: 'outerLoopPath',
|
|
245
|
+
});
|
|
246
|
+
/**
|
|
247
|
+
* The same three forms, restricted to the deploy wrapper.
|
|
248
|
+
*
|
|
249
|
+
* The deploy rows live in `ask`, so a deploy stays a decision a person makes rather than one an
|
|
250
|
+
* unattended run makes for them — and because `ask` is evaluated before `allow`, all three forms
|
|
251
|
+
* are asked about rather than one. Asking about a single spelling would leave the other two
|
|
252
|
+
* matching `allow` and running unattended, which is the opposite of what an `ask` entry is for. A
|
|
253
|
+
* configuration with no `deploy.command` selects no deploy wrapper, and these rows then expand to
|
|
254
|
+
* nothing at all.
|
|
255
|
+
*/
|
|
256
|
+
const DEPLOY_ROW_TOKENS = Object.freeze({
|
|
257
|
+
invocation: 'deployInvocation',
|
|
258
|
+
file: 'deployFile',
|
|
259
|
+
path: 'deployPath',
|
|
260
|
+
});
|
|
261
|
+
/** A row family's token names as a list, for the "is this a form row?" test and the known-token set. */
|
|
262
|
+
function tokenNames(tokens) {
|
|
263
|
+
return [tokens.invocation, tokens.file, tokens.path];
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* What may never appear in a permission entry, and how a message names it.
|
|
267
|
+
*
|
|
268
|
+
* The quote characters are failure mode 1 and the operators are failure mode 2 (module header).
|
|
269
|
+
* `$` is on the list for the same reason as the operators: a substitution is a different string to
|
|
270
|
+
* whatever the guard matched, so an entry containing one cannot be relied on to match anything.
|
|
271
|
+
* `///` is the third: a file rule marks an absolute path with a `//` prefix and the substituted
|
|
272
|
+
* path brings its own leading slash, so a template row that adds a second one produces a rule that
|
|
273
|
+
* silently matches no file — the same class of failure as a missed worktree glob, and just as quiet.
|
|
274
|
+
*/
|
|
275
|
+
const FORBIDDEN_IN_ENTRY = Object.freeze([
|
|
276
|
+
['///', 'a third slash on the `//` absolute-path prefix, which leaves a rule matching no file'],
|
|
277
|
+
['"', 'a double quote'],
|
|
278
|
+
["'", 'a single quote'],
|
|
279
|
+
['`', 'a backquote'],
|
|
280
|
+
['&', 'a shell operator (&)'],
|
|
281
|
+
[';', 'a shell operator (;)'],
|
|
282
|
+
['|', 'a shell operator (|)'],
|
|
283
|
+
['$', 'a shell substitution ($)'],
|
|
284
|
+
['>', 'a shell redirect (>)'],
|
|
285
|
+
['<', 'a shell redirect (<)'],
|
|
286
|
+
['\n', 'a newline'],
|
|
287
|
+
]);
|
|
288
|
+
/** The permission lists checked by {@link assertRunnable}, in the order the template writes them. */
|
|
289
|
+
const PERMISSION_LISTS = Object.freeze(['allow', 'deny', 'ask']);
|
|
290
|
+
/** How many `allow` entries one selected script must contribute — the three forms of choice 1. */
|
|
291
|
+
const FORMS_PER_SCRIPT = 3;
|
|
292
|
+
/**
|
|
293
|
+
* Read one of this generator's templates and parse it.
|
|
294
|
+
*
|
|
295
|
+
* **Parsed before anything is substituted** — see choice 3 in the module header. Both a template
|
|
296
|
+
* that does not parse and one that is not an object are packaging faults the adopter cannot act on,
|
|
297
|
+
* so both name the template and exit {@link EXIT.INTERNAL}.
|
|
298
|
+
*/
|
|
299
|
+
function readTemplateObject(templatePath) {
|
|
300
|
+
const text = readTemplate(templatePath);
|
|
301
|
+
let parsed;
|
|
302
|
+
try {
|
|
303
|
+
parsed = JSON.parse(text);
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
307
|
+
throw internal(`the permission-profile template ${templatePath} is not valid JSON (${detail})`);
|
|
308
|
+
}
|
|
309
|
+
if (!isJsonObject(parsed))
|
|
310
|
+
throw internal(`the permission-profile template ${templatePath} is not a JSON object`);
|
|
311
|
+
return parsed;
|
|
312
|
+
}
|
|
313
|
+
/** Two lists in order, with anything already in the first one not repeated from the second. */
|
|
314
|
+
function concatUnique(base, added) {
|
|
315
|
+
const out = [...base];
|
|
316
|
+
const seen = new Set(out.map((entry) => JSON.stringify(entry)));
|
|
317
|
+
for (const entry of added) {
|
|
318
|
+
const key = JSON.stringify(entry);
|
|
319
|
+
if (seen.has(key))
|
|
320
|
+
continue;
|
|
321
|
+
seen.add(key);
|
|
322
|
+
out.push(entry);
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Merge the QA fragment into the base profile, in place.
|
|
328
|
+
*
|
|
329
|
+
* Three rules, and one refusal:
|
|
330
|
+
*
|
|
331
|
+
* - a key the base does not have is **added**, which is how `enabledMcpjsonServers` appears at all;
|
|
332
|
+
* - two lists are **concatenated**, de-duplicated, base first — so the fragment's `allow` entries
|
|
333
|
+
* and its rationale lines land after the base's rather than replacing them;
|
|
334
|
+
* - two objects are **merged** recursively, which is what carries `permissions.allow` down;
|
|
335
|
+
* - anything else — a key the base already sets to a scalar — **throws**. The fragment adds the
|
|
336
|
+
* browser half; it does not get to overrule a decision the base profile made, and a value with two
|
|
337
|
+
* producers is how the two spellings drift apart.
|
|
338
|
+
*/
|
|
339
|
+
function mergeInto(base, fragment, at) {
|
|
340
|
+
for (const [key, value] of Object.entries(fragment)) {
|
|
341
|
+
const where = at === '' ? key : `${at}.${key}`;
|
|
342
|
+
const existing = base[key];
|
|
343
|
+
if (existing === undefined) {
|
|
344
|
+
base[key] = value;
|
|
345
|
+
}
|
|
346
|
+
else if (Array.isArray(existing) && Array.isArray(value)) {
|
|
347
|
+
base[key] = concatUnique(existing, value);
|
|
348
|
+
}
|
|
349
|
+
else if (isJsonObject(existing) && isJsonObject(value)) {
|
|
350
|
+
mergeInto(existing, value, where);
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
throw internal(`the interactive-test fragment redeclares \`${where}\`, which the base permission-profile template already sets: the fragment adds the browser half rather than overruling the base, and one entry with two producers is how two spellings of it drift apart`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* The repo-relative path inside a script invocation — `bash scripts/test.sh` → `scripts/test.sh`.
|
|
359
|
+
*
|
|
360
|
+
* Derived from the invocation rather than re-joined from `scriptsDir` and the file name, so forms
|
|
361
|
+
* (ii) and (iii) name the same file form (i) does even where the two spellings would differ: a
|
|
362
|
+
* `scriptsDir` of `.` makes {@link scriptInvocation} return `bash test.sh`, while a naive join would
|
|
363
|
+
* produce `./test.sh`. It throws rather than guessing if the invocation stops looking like
|
|
364
|
+
* `bash <one path>`, because at that point this module's three forms no longer describe the string
|
|
365
|
+
* the config holds.
|
|
366
|
+
*/
|
|
367
|
+
function invokedPath(invocation) {
|
|
368
|
+
const path = invocation.startsWith(INVOCATION_PREFIX) ? invocation.slice(INVOCATION_PREFIX.length) : '';
|
|
369
|
+
if (path === '' || path.includes(' ')) {
|
|
370
|
+
throw internal(`the script invocation ${JSON.stringify(invocation)} is not of the form "${INVOCATION_PREFIX}<path>", so the permission profile cannot derive its absolute and worktree forms from it`);
|
|
371
|
+
}
|
|
372
|
+
return path;
|
|
373
|
+
}
|
|
374
|
+
/** One selected wrapper, built from the table's row and the single invocation formatter. */
|
|
375
|
+
function selected(key, file, scriptsDir) {
|
|
376
|
+
const invocation = wrapperInvocation(scriptsDir, file);
|
|
377
|
+
return { key, file, invocation, path: invokedPath(invocation) };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* The wrappers a config implies, in {@link WRAPPER_SCRIPTS} order — the fallback for a caller with
|
|
381
|
+
* no `written` list.
|
|
382
|
+
*
|
|
383
|
+
* **The rule is not restated here: {@link selectWrapper} *is* the rule**, and the wrapper generator
|
|
384
|
+
* that writes the files reaches its own answer by calling the same function. That is what makes this
|
|
385
|
+
* a fallback rather than a second opinion — a profile can no longer allow-list a wrapper that was
|
|
386
|
+
* never written, because there is no second selection to drift from the first.
|
|
387
|
+
*
|
|
388
|
+
* A caller that ran the wrapper generator passes its `written` list to {@link renderProfile}, which
|
|
389
|
+
* is authoritative and skips this path entirely; the fixture tests assert the two agree end to end,
|
|
390
|
+
* over the filesystem this module deliberately never touches.
|
|
391
|
+
*/
|
|
392
|
+
export function selectWrapperScripts(config) {
|
|
393
|
+
const scriptsDir = normalizeRepoDir(config.scriptsDir ?? DEFAULTS.scriptsDir);
|
|
394
|
+
const result = [];
|
|
395
|
+
for (const { key, file } of WRAPPER_SCRIPTS) {
|
|
396
|
+
if (!selectWrapper(config, key).selected)
|
|
397
|
+
continue;
|
|
398
|
+
result.push(selected(key, file, scriptsDir));
|
|
399
|
+
}
|
|
400
|
+
return result;
|
|
401
|
+
}
|
|
402
|
+
/** The `written` list mapped onto this module's shape, keeping the invocation string it carries. */
|
|
403
|
+
function fromWritten(written) {
|
|
404
|
+
return written.map(({ key, file, invocation }) => ({ key, file, invocation, path: invokedPath(invocation) }));
|
|
405
|
+
}
|
|
406
|
+
/** One outer-loop row, built from the table's row and the same single invocation formatter. */
|
|
407
|
+
function selectedOuterLoop(script, scriptsDir) {
|
|
408
|
+
const file = outerLoopRelativePath(script);
|
|
409
|
+
const invocation = scriptInvocation(scriptsDir, file);
|
|
410
|
+
return { file, invocation, path: invokedPath(invocation) };
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* The outer-loop scripts a config implies — the fallback for a caller with no `writtenOuterLoop`
|
|
414
|
+
* list.
|
|
415
|
+
*
|
|
416
|
+
* **The rule is not restated here either: {@link OuterLoopScript.agentInvocable} *is* the rule**,
|
|
417
|
+
* and it is decided in the table beside the row that writes the file. There is no condition to
|
|
418
|
+
* evaluate and therefore nothing for a second opinion to disagree with — a row that is written is a
|
|
419
|
+
* row that may be listed, and only if its own flag says an agent runs it.
|
|
420
|
+
*
|
|
421
|
+
* Exported for the same caller {@link selectWrapperScripts} is: one that has a config and no write
|
|
422
|
+
* plan.
|
|
423
|
+
*/
|
|
424
|
+
export function selectOuterLoopScripts(config) {
|
|
425
|
+
const scriptsDir = normalizeRepoDir(config.scriptsDir ?? DEFAULTS.scriptsDir);
|
|
426
|
+
return OUTER_LOOP_SCRIPTS.filter((script) => script.agentInvocable).map((script) => selectedOuterLoop(script, scriptsDir));
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The `writtenOuterLoop` list mapped onto this module's shape, keeping each row's own invocation
|
|
430
|
+
* string and dropping every row an agent is not the thing that runs.
|
|
431
|
+
*/
|
|
432
|
+
function fromWrittenOuterLoop(written) {
|
|
433
|
+
return written
|
|
434
|
+
.filter((script) => script.agentInvocable)
|
|
435
|
+
.map((script) => ({
|
|
436
|
+
file: outerLoopRelativePath(script),
|
|
437
|
+
invocation: script.invocation,
|
|
438
|
+
path: invokedPath(script.invocation),
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
/** The values one script contributes to a row of the given family. */
|
|
442
|
+
function rowValues(script, tokens) {
|
|
443
|
+
return {
|
|
444
|
+
[tokens.invocation]: script.invocation,
|
|
445
|
+
[tokens.file]: script.file,
|
|
446
|
+
[tokens.path]: script.path,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
/** A family, built from its token names and the scripts its rows are emitted for. */
|
|
450
|
+
function rowFamily(tokens, scripts) {
|
|
451
|
+
return { tokens: tokenNames(tokens), values: scripts.map((script) => rowValues(script, tokens)) };
|
|
452
|
+
}
|
|
453
|
+
/** True when `text` carries any of `tokens`. */
|
|
454
|
+
function usesAny(text, tokens) {
|
|
455
|
+
return tokens.some((token) => text.includes(`{{${token}}}`));
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Substitute one template string's tokens through {@link renderTemplate}.
|
|
459
|
+
*
|
|
460
|
+
* **This is the caller that needs `known` separate from `values`**, and the module's row expansion is
|
|
461
|
+
* why: a per-wrapper row token is legal *in the template* but has a value only inside a row being
|
|
462
|
+
* emitted for a script. Passing both sets keeps the two failures apart — a token the generator has
|
|
463
|
+
* never heard of is a template that has outrun this module, while a known token in the wrong position
|
|
464
|
+
* is a row family that lost track of its own extent — because they have different causes and
|
|
465
|
+
* different fixes.
|
|
466
|
+
*
|
|
467
|
+
* `assertNoneSurvive` is off: the post-substitution sweep for this generator is
|
|
468
|
+
* {@link assertRunnable}, which runs over every rendered *entry* rather than each string as it is
|
|
469
|
+
* produced, and reports a leftover token as the stall it would cause.
|
|
470
|
+
*/
|
|
471
|
+
function substitute(text, values, known) {
|
|
472
|
+
return renderTemplate(text, values, { describe: 'the permission-profile template', known });
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Render one array, expanding its **form rows**.
|
|
476
|
+
*
|
|
477
|
+
* A run of consecutive rows belonging to one family is expanded script-major: the whole run is
|
|
478
|
+
* emitted once per selected script, so a script's three forms stay together and read as the unit
|
|
479
|
+
* they are. A family with no selected script contributes nothing at all — which is how a
|
|
480
|
+
* configuration without `deploy.command` produces a profile with no deploy entry rather than one
|
|
481
|
+
* naming a wrapper that was never written.
|
|
482
|
+
*/
|
|
483
|
+
function renderArray(entries, globals, families, known) {
|
|
484
|
+
const out = [];
|
|
485
|
+
for (let index = 0; index < entries.length;) {
|
|
486
|
+
const entry = entries[index];
|
|
487
|
+
const family = typeof entry === 'string' ? families.find((candidate) => usesAny(entry, candidate.tokens)) : undefined;
|
|
488
|
+
if (family === undefined) {
|
|
489
|
+
out.push(renderValue(entry, globals, families, known));
|
|
490
|
+
index += 1;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const run = [];
|
|
494
|
+
while (index < entries.length) {
|
|
495
|
+
const row = entries[index];
|
|
496
|
+
if (typeof row !== 'string' || !usesAny(row, family.tokens))
|
|
497
|
+
break;
|
|
498
|
+
run.push(row);
|
|
499
|
+
index += 1;
|
|
500
|
+
}
|
|
501
|
+
for (const values of family.values) {
|
|
502
|
+
for (const row of run)
|
|
503
|
+
out.push(substitute(row, { ...globals, ...values }, known));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
/** Render any template value: strings are substituted, arrays may expand, objects are walked. */
|
|
509
|
+
function renderValue(value, globals, families, known) {
|
|
510
|
+
if (typeof value === 'string')
|
|
511
|
+
return substitute(value, globals, known);
|
|
512
|
+
if (Array.isArray(value))
|
|
513
|
+
return renderArray(value, globals, families, known);
|
|
514
|
+
if (value !== null && typeof value === 'object') {
|
|
515
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, renderValue(nested, globals, families, known)]));
|
|
516
|
+
}
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
/** The rendered profile's `permissions` object, or a throw when the template lost its shape. */
|
|
520
|
+
function permissionsOf(profile) {
|
|
521
|
+
const permissions = profile['permissions'];
|
|
522
|
+
if (permissions === null || typeof permissions !== 'object' || Array.isArray(permissions)) {
|
|
523
|
+
throw internal('the permission-profile template has no `permissions` object, so nothing could be checked for the shapes that stall an unattended run');
|
|
524
|
+
}
|
|
525
|
+
return permissions;
|
|
526
|
+
}
|
|
527
|
+
/** The entries of one permission list, or an empty list when the template does not carry it. */
|
|
528
|
+
function entriesOf(permissions, list) {
|
|
529
|
+
const value = permissions[list];
|
|
530
|
+
if (value === undefined)
|
|
531
|
+
return [];
|
|
532
|
+
if (!Array.isArray(value))
|
|
533
|
+
throw internal(`the permission-profile template's \`permissions.${list}\` is not a list`);
|
|
534
|
+
return value.map((entry) => {
|
|
535
|
+
if (typeof entry !== 'string')
|
|
536
|
+
throw internal(`\`permissions.${list}\` carries an entry that is not a string`);
|
|
537
|
+
return entry;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* A `Read` rule over an absolute path, in the profile's own two-slash spelling: the `//` prefix that
|
|
542
|
+
* anchors a rule at the filesystem root, then the path, which brings its own leading slash. The
|
|
543
|
+
* generated profile's `Read(//<repo root>/**)` entries are this, and the template's `_README` is
|
|
544
|
+
* where the two-slash rule is stated.
|
|
545
|
+
*
|
|
546
|
+
* It is also one of the two forms a **plugin-root** entry is written in: `doctor`'s
|
|
547
|
+
* `plugin-permissions` check prints those lines for an operator to paste, and
|
|
548
|
+
* {@link pluginRootEntryTarget} reads one back. Nothing in this generator emits one for a plugin
|
|
549
|
+
* root — the module header's standing decision.
|
|
550
|
+
*/
|
|
551
|
+
export function readRule(absolutePath) {
|
|
552
|
+
return `Read(/${absolutePath}/**)`;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* A wrapper-script rule, in the unquoted `Bash(bash <path>:*)` form every generated one uses — and
|
|
556
|
+
* the other plugin-root form, for a helper under the plugin's own scripts directory. Same two
|
|
557
|
+
* consumers, and the same standing decision, as {@link readRule}.
|
|
558
|
+
*/
|
|
559
|
+
export function bashScriptRule(absolutePath) {
|
|
560
|
+
return `Bash(${INVOCATION_PREFIX}${absolutePath}:*)`;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* The fixed halves of the two builders above, taken from the builders themselves over a path none of
|
|
564
|
+
* those halves can contain — so the inverse below recognises each form rather than spelling it a
|
|
565
|
+
* second time, and a change to either builder cannot leave that inverse matching the old shape. Same
|
|
566
|
+
* derivation, and the same reason, as `doctor/checks.ts`'s `HELPER_RULE_OPEN`.
|
|
567
|
+
*/
|
|
568
|
+
const RULE_PROBE = '/probe';
|
|
569
|
+
const [READ_RULE_OPEN = '', READ_RULE_CLOSE = ''] = readRule(RULE_PROBE).split(RULE_PROBE);
|
|
570
|
+
const [BASH_RULE_OPEN = '', BASH_RULE_CLOSE = ''] = bashScriptRule(RULE_PROBE).split(RULE_PROBE);
|
|
571
|
+
/** What a directory may not carry, or the entry names a pattern rather than a directory. */
|
|
572
|
+
const GLOB_CHARACTERS = Object.freeze(['*', '?', '[', ']', '{', '}']);
|
|
573
|
+
/** The text an entry carries between one form's fixed opening and closing, if it is that form. */
|
|
574
|
+
function between(entry, open, close) {
|
|
575
|
+
if (entry.length < open.length + close.length)
|
|
576
|
+
return undefined;
|
|
577
|
+
if (!entry.startsWith(open) || !entry.endsWith(close))
|
|
578
|
+
return undefined;
|
|
579
|
+
return entry.slice(open.length, entry.length - close.length);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* The directory a {@link bashScriptRule} entry's path sits two levels under, via the `scripts`
|
|
583
|
+
* segment `machine/plugins.ts`'s `pluginScriptsDir` joins — imported from there rather than copied,
|
|
584
|
+
* so a rename of that directory reaches this reader. Importing the name costs the extractor nothing
|
|
585
|
+
* it is relied on for: it still reads a *string* back and answers the same way on a machine with no
|
|
586
|
+
* plugin installed, which is what lets `doctor` and the carry-forward share it.
|
|
587
|
+
*/
|
|
588
|
+
function bashScriptRuleTarget(entry) {
|
|
589
|
+
const path = between(entry, BASH_RULE_OPEN, BASH_RULE_CLOSE);
|
|
590
|
+
if (path === undefined)
|
|
591
|
+
return undefined;
|
|
592
|
+
const scriptsDir = dirname(path);
|
|
593
|
+
if (basename(scriptsDir) !== SCRIPTS_DIRNAME)
|
|
594
|
+
return undefined;
|
|
595
|
+
return dirname(scriptsDir);
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* The absolute directory a `permissions.allow` entry names, if it is written in one of the two forms
|
|
599
|
+
* above — `Read(/<abs>/**)` and `Bash(bash <abs>/scripts/<name>:*)` both answer `<abs>` — and
|
|
600
|
+
* `undefined` for everything else: an `mcp__…` entry, a command rule with no `bash ` prefix, a rule
|
|
601
|
+
* whose path is repo-relative (`Bash(bash scripts/test.sh:*)`, the form {@link scriptInvocation}
|
|
602
|
+
* returns), and a rule whose directory carries a glob character, which names a pattern rather than a
|
|
603
|
+
* directory — the sibling-worktree spelling.
|
|
604
|
+
*
|
|
605
|
+
* **Telling a plugin root from the repository root is deliberately the caller's job.** Under the
|
|
606
|
+
* default `scriptsDir`, a wrapper's repo-root-absolute entry renders byte-identically to what
|
|
607
|
+
* {@link bashScriptRule} builds for a plugin helper, and `Read(//<repo root>/**)` to what
|
|
608
|
+
* {@link readRule} builds for a plugin root — nothing in the entry string tells them apart, so an
|
|
609
|
+
* extractor that answered for one and not the other would be inventing a difference the string does
|
|
610
|
+
* not carry. This decides only which absolute directory an entry names; a caller asks whether that
|
|
611
|
+
* directory is one of the roots it resolved, or none of them and not its own repository root.
|
|
612
|
+
*/
|
|
613
|
+
export function pluginRootEntryTarget(entry) {
|
|
614
|
+
const directory = between(entry, READ_RULE_OPEN, READ_RULE_CLOSE) ?? bashScriptRuleTarget(entry);
|
|
615
|
+
if (directory === undefined || !isAbsolute(directory))
|
|
616
|
+
return undefined;
|
|
617
|
+
return GLOB_CHARACTERS.some((character) => directory.includes(character)) ? undefined : directory;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* A directory in the one spelling {@link isUnderDirectory} compares against: no trailing separator,
|
|
621
|
+
* no `.` or `..` segment. Exported so the normalisation the predicate assumes is stated once, beside
|
|
622
|
+
* the predicate, rather than at each root a caller happens to resolve.
|
|
623
|
+
*/
|
|
624
|
+
export function normalizedRoot(path) {
|
|
625
|
+
return join(path, '.');
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Is `directory` `root` itself, or a path below it? Matched on the separator, so no sibling counts.
|
|
629
|
+
*
|
|
630
|
+
* Exported beside {@link pluginRootEntryTarget} because that function deliberately does not decide
|
|
631
|
+
* whether the directory it returns is a plugin root, this checkout, or neither — this is the test
|
|
632
|
+
* its two callers apply to the answer, and one copy is what keeps `init --force` and `doctor`'s
|
|
633
|
+
* `plugin-permissions` check from disagreeing about the same entry.
|
|
634
|
+
*/
|
|
635
|
+
export function isUnderDirectory(directory, root) {
|
|
636
|
+
return directory === root || directory.startsWith(`${root}${sep}`);
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* `entry` or `entries`, so a line agrees with the count it carries. Exported beside
|
|
640
|
+
* {@link isUnderDirectory} because both sides of the entry vocabulary count the same noun —
|
|
641
|
+
* `init --force` reporting what it carried forward, `doctor` reporting what the profile owes.
|
|
642
|
+
*/
|
|
643
|
+
export function entryWord(count) {
|
|
644
|
+
return count === 1 ? 'entry' : 'entries';
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* The check that makes failure modes 1 and 2 unproducible rather than merely discouraged: no entry
|
|
648
|
+
* in any permission list may carry a quote, a shell operator or a leftover token.
|
|
649
|
+
*
|
|
650
|
+
* It runs over the **rendered** entries, so it covers both what the template author wrote and what
|
|
651
|
+
* substitution put there — a hand-quoted template row, and a row family that emitted a token it
|
|
652
|
+
* never substituted. The adopting repository's own values reach these entries too, but never in a
|
|
653
|
+
* shape this check can find: {@link assertRepositoryValuesUsable} refuses those before substitution,
|
|
654
|
+
* which is what leaves every failure arriving here one the adopter could not have caused, and so
|
|
655
|
+
* {@link EXIT.INTERNAL}.
|
|
656
|
+
*/
|
|
657
|
+
function assertRunnable(profile) {
|
|
658
|
+
const permissions = permissionsOf(profile);
|
|
659
|
+
for (const list of PERMISSION_LISTS) {
|
|
660
|
+
for (const entry of entriesOf(permissions, list)) {
|
|
661
|
+
for (const [needle, described] of FORBIDDEN_IN_ENTRY) {
|
|
662
|
+
if (entry.includes(needle)) {
|
|
663
|
+
throw internal(`the generated permission entry ${JSON.stringify(entry)} in \`permissions.${list}\` contains ${described}, which the guard matches literally: the entry would fall through to a prompt and stall an unattended run`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (containsToken(entry)) {
|
|
667
|
+
throw internal(`the generated permission entry ${JSON.stringify(entry)} in \`permissions.${list}\` still carries an unsubstituted token`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* The pairing invariant, in the direction this module owns: every selected script — wrapper and
|
|
674
|
+
* agent-invocable outer-loop row alike — contributes exactly {@link FORMS_PER_SCRIPT} `allow`
|
|
675
|
+
* entries.
|
|
676
|
+
*
|
|
677
|
+
* Checked rather than assumed because the three forms live in the template, where a well-meaning
|
|
678
|
+
* edit can drop one — and a dropped form is invisible until the run that used that spelling hangs.
|
|
679
|
+
* The other direction (no entry names a script that was not written, and none names one an agent
|
|
680
|
+
* must not run) is the fixture tests', which can see the filesystem this module deliberately never
|
|
681
|
+
* touches.
|
|
682
|
+
*/
|
|
683
|
+
function assertEveryScriptListed(profile, scripts) {
|
|
684
|
+
const allow = entriesOf(permissionsOf(profile), 'allow');
|
|
685
|
+
for (const script of scripts) {
|
|
686
|
+
const count = allow.filter((entry) => entry.includes(script.file)).length;
|
|
687
|
+
if (count !== FORMS_PER_SCRIPT) {
|
|
688
|
+
throw internal(`the script ${script.file} is allow-listed in ${count} form(s) rather than ${FORMS_PER_SCRIPT}, so a caller using one of the missing spellings would match neither \`allow\` nor \`deny\``);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* The servers the browser closure is about, read from {@link QA_TEMPLATE_PATH}'s
|
|
694
|
+
* `enabledMcpjsonServers` — the single declaration of which browser servers a run starts. Read
|
|
695
|
+
* rather than restated here: a browser server added there is covered with no second edit, and an
|
|
696
|
+
* MCP namespace that is not a browser one is not mistaken for one.
|
|
697
|
+
*/
|
|
698
|
+
export function browserServerNames() {
|
|
699
|
+
const fragment = readTemplateObject(QA_TEMPLATE_PATH);
|
|
700
|
+
const enabled = fragment[ENABLED_SERVERS_KEY];
|
|
701
|
+
if (!Array.isArray(enabled)) {
|
|
702
|
+
throw internal(`the interactive-test fragment ${QA_TEMPLATE_PATH} has no \`${ENABLED_SERVERS_KEY}\` list, so the browser servers a \`deny\` may not name cannot be read`);
|
|
703
|
+
}
|
|
704
|
+
return enabled.map((entry) => {
|
|
705
|
+
if (typeof entry !== 'string') {
|
|
706
|
+
throw internal(`\`${ENABLED_SERVERS_KEY}\` in ${QA_TEMPLATE_PATH} carries an entry that is not a string`);
|
|
707
|
+
}
|
|
708
|
+
return entry;
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Does this permission entry name a browser tool — in either spelling a settings file uses, the
|
|
713
|
+
* whole-server `mcp__<server>` and the per-tool `mcp__<server>__<tool>`, with or without a trailing
|
|
714
|
+
* `*`? Matched against {@link browserServerNames} rather than on the bare `mcp__` prefix, so a deny
|
|
715
|
+
* of an unrelated MCP server is neither refused here nor reported as a browser deny by `doctor`.
|
|
716
|
+
*/
|
|
717
|
+
export function namesBrowserTool(entry) {
|
|
718
|
+
return browserServerNames().some((server) => {
|
|
719
|
+
const namespace = `${MCP_ENTRY_PREFIX}${server}`;
|
|
720
|
+
const index = entry.indexOf(namespace);
|
|
721
|
+
if (index < 0)
|
|
722
|
+
return false;
|
|
723
|
+
const rest = entry.slice(index + namespace.length);
|
|
724
|
+
return rest === '' || rest.startsWith('__') || rest.startsWith('*');
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* The browser closure, in the direction a generated file can get wrong: **no `deny` entry may name a
|
|
729
|
+
* browser tool.**
|
|
730
|
+
*
|
|
731
|
+
* A deny is evaluated before any allow and cannot be overridden, so a namespace deny here would
|
|
732
|
+
* revoke the interactive-test agent's own grant along with everyone else's. The closure is made per
|
|
733
|
+
* agent instead, by each agent definition's `tools:` allowlist. Checked on the rendered profile
|
|
734
|
+
* rather than trusted to the template, because the plausible way this gets re-introduced is an
|
|
735
|
+
* adopter-shaped "tighten the deny list" edit to the shipped template.
|
|
736
|
+
*/
|
|
737
|
+
function assertNoBrowserDeny(profile) {
|
|
738
|
+
for (const entry of entriesOf(permissionsOf(profile), 'deny')) {
|
|
739
|
+
if (!namesBrowserTool(entry))
|
|
740
|
+
continue;
|
|
741
|
+
throw internal(`the generated \`permissions.deny\` names the browser tool ${JSON.stringify(entry)}, and a deny is evaluated before any allow and cannot be overridden, so it would revoke the interactive-test agent's own grant: that closure is made per agent, by each agent definition's tools allowlist, and never here`);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/** The servers the profile starts for the run, or none when it carries no such key. */
|
|
745
|
+
function enabledServers(profile) {
|
|
746
|
+
const value = profile[ENABLED_SERVERS_KEY];
|
|
747
|
+
if (value === undefined)
|
|
748
|
+
return [];
|
|
749
|
+
if (!Array.isArray(value))
|
|
750
|
+
throw internal(`the generated \`${ENABLED_SERVERS_KEY}\` is not a list`);
|
|
751
|
+
return value.map((entry) => {
|
|
752
|
+
if (typeof entry !== 'string')
|
|
753
|
+
throw internal(`\`${ENABLED_SERVERS_KEY}\` carries an entry that is not a string`);
|
|
754
|
+
return entry;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* The enablement and the grants have to agree, and the check runs in **both** directions because the
|
|
759
|
+
* two disagreements fail differently and neither fails loudly:
|
|
760
|
+
*
|
|
761
|
+
* - a tool allowed whose server is not started does not exist at run time, and an un-loaded tool in
|
|
762
|
+
* print mode stalls rather than prompting — the failure this whole profile exists to prevent;
|
|
763
|
+
* - a server started whose tools are all un-allowed launches a browser the run can never call, which
|
|
764
|
+
* costs every session the tool schemas and every run a process, for nothing.
|
|
765
|
+
*
|
|
766
|
+
* **A profile with neither half is the agreeing case, and passes deliberately.** That is every
|
|
767
|
+
* profile the fragment was not merged into — the phase off, or the phase on with a mobile driver —
|
|
768
|
+
* and it is why {@link enabledServers} answers "none" for an absent key rather than refusing one:
|
|
769
|
+
* both loops below then run over empty sets, which is the two sides agreeing that there is no
|
|
770
|
+
* browser wiring, not a check that was skipped.
|
|
771
|
+
*/
|
|
772
|
+
function assertBrowserWiring(profile) {
|
|
773
|
+
const enabled = enabledServers(profile);
|
|
774
|
+
const referenced = new Set();
|
|
775
|
+
for (const entry of entriesOf(permissionsOf(profile), 'allow')) {
|
|
776
|
+
if (!entry.startsWith(MCP_ENTRY_PREFIX))
|
|
777
|
+
continue;
|
|
778
|
+
const match = MCP_SERVER_PATTERN.exec(entry);
|
|
779
|
+
if (match === null) {
|
|
780
|
+
throw internal(`the generated allow entry ${JSON.stringify(entry)} starts with ${MCP_ENTRY_PREFIX} but does not name a server and a tool, so nothing can check that the server it belongs to is started`);
|
|
781
|
+
}
|
|
782
|
+
referenced.add(match[1]);
|
|
783
|
+
}
|
|
784
|
+
for (const server of referenced) {
|
|
785
|
+
if (enabled.includes(server))
|
|
786
|
+
continue;
|
|
787
|
+
throw internal(`the generated profile allow-lists a tool of the MCP server ${JSON.stringify(server)}, which \`${ENABLED_SERVERS_KEY}\` does not start: the tool would not exist at run time, and an un-loaded tool stalls an unattended run rather than failing it`);
|
|
788
|
+
}
|
|
789
|
+
for (const server of enabled) {
|
|
790
|
+
if (referenced.has(server))
|
|
791
|
+
continue;
|
|
792
|
+
throw internal(`the generated \`${ENABLED_SERVERS_KEY}\` starts the MCP server ${JSON.stringify(server)}, which no allow entry names a tool of: the run would pay for the server's tool schemas and its process and be unable to call it`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/** The flag supplying the reference toolchain's directory, as a message should name it. */
|
|
796
|
+
const TOOLCHAIN_FLAG = '--reference-toolchain-path';
|
|
797
|
+
/**
|
|
798
|
+
* {@link FORBIDDEN_IN_ENTRY} minus its `///` row, which is about a **file** rule's `//` absolute-path
|
|
799
|
+
* prefix and says nothing about a command entry. Every other row applies to both.
|
|
800
|
+
*/
|
|
801
|
+
const FORBIDDEN_IN_COMMAND = FORBIDDEN_IN_ENTRY.filter(([needle]) => needle !== '///');
|
|
802
|
+
/** The fix for a *command* value: the one place a command carrying shell syntax can still live. */
|
|
803
|
+
const COMMAND_REMEDY = "Take it out of the value, or put the command in a wrapper script and let that script's literal path be what is allow-listed";
|
|
804
|
+
/**
|
|
805
|
+
* Refuse an adopter-supplied value that cannot be made into an entry the guard will match.
|
|
806
|
+
*
|
|
807
|
+
* The same shapes {@link assertRunnable} catches, raised **before** the entry is built and with the
|
|
808
|
+
* ordinary exit code: a toolchain path, a configured command or a configured directory carrying a
|
|
809
|
+
* quote or a shell operator is a repository-side mistake with a repository-side fix, not the
|
|
810
|
+
* packaging fault that check reports. `remedy` is what makes the message actionable, and it differs
|
|
811
|
+
* per value — a command moves into a wrapper, a directory is renamed — so it is the caller's.
|
|
812
|
+
*/
|
|
813
|
+
function assertUsableInEntry(value, described, remedy = COMMAND_REMEDY) {
|
|
814
|
+
for (const [needle, why] of FORBIDDEN_IN_COMMAND) {
|
|
815
|
+
if (!value.includes(needle))
|
|
816
|
+
continue;
|
|
817
|
+
throw new HarnessError(`${described} contains ${why}, and the permission guard matches an entry literally: an entry built from it would match neither allow nor deny, which stalls an unattended run rather than failing it. ${remedy}`);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Screen the values the adopting repository supplies, before any of them is substituted into an entry.
|
|
822
|
+
*
|
|
823
|
+
* Every entry carries the checkout path, and most carry `scriptsDir`, `stateDir` or the worktree glob
|
|
824
|
+
* built from `projectName`. The schema constrains `projectName` and `scriptsDir` by length alone, and
|
|
825
|
+
* nothing constrains where a repository is cloned to — so without this, a checkout at `~/work/R&D/app`
|
|
826
|
+
* and an `init --project-name "Dan's App"` each reach {@link assertRunnable} and are billed to this
|
|
827
|
+
* CLI, when the fix is one rename or one flag. `stateDir` already carries a schema pattern that
|
|
828
|
+
* excludes every one of these characters and is screened anyway, so this is the whole set the profile
|
|
829
|
+
* is built from rather than the subset that happens to be unguarded today.
|
|
830
|
+
*
|
|
831
|
+
* The checkout path goes first, and that is what lets the `projectName` message name the key without
|
|
832
|
+
* qualification: the value that key falls back to is a segment of that path, so a directory name
|
|
833
|
+
* carrying one of these characters has already been refused — with the remedy that applies to a
|
|
834
|
+
* directory — before the name derived from it is looked at.
|
|
835
|
+
*/
|
|
836
|
+
function assertRepositoryValuesUsable({ repoRoot, projectName, scriptsDir, stateDir }) {
|
|
837
|
+
assertUsableInEntry(repoRoot, `the repository root ${JSON.stringify(repoRoot)}`, 'Every entry in the profile is built from this path, so the fix is on disk rather than in the config: move or rename the checkout so that no segment of its path carries it, and run init again');
|
|
838
|
+
assertUsableInEntry(projectName, `the configured projectName ${JSON.stringify(projectName)}`, `The sibling-worktree entries are built from it: set projectName in ${CONFIG_FILENAME} to a name carrying no such character (\`config set projectName <name>\`, or \`init --project-name <name>\` on a first run), then run init again`);
|
|
839
|
+
assertUsableInEntry(scriptsDir, `the configured scriptsDir ${JSON.stringify(scriptsDir)}`, `Set scriptsDir in ${CONFIG_FILENAME} to a directory whose path carries no such character (\`config set scriptsDir <dir>\`), then run init again, which writes the scripts to the location it names`);
|
|
840
|
+
assertUsableInEntry(stateDir, `the configured stateDir ${JSON.stringify(stateDir)}`, `Set stateDir in ${CONFIG_FILENAME} to a directory whose path carries no such character (\`config set stateDir <dir>\`), then run init again`);
|
|
841
|
+
}
|
|
842
|
+
/** Append a generated line to the profile's rationale block. */
|
|
843
|
+
function appendReadme(profile, line) {
|
|
844
|
+
const readme = profile[README_KEY];
|
|
845
|
+
if (!Array.isArray(readme)) {
|
|
846
|
+
throw internal(`the permission-profile template has no \`${README_KEY}\` list, so a generated group of entries could not be explained in the file it was written into`);
|
|
847
|
+
}
|
|
848
|
+
readme.push(line);
|
|
849
|
+
}
|
|
850
|
+
/** Append generated `allow` entries after the template's own, adding none of them twice. */
|
|
851
|
+
function appendAllow(profile, entries) {
|
|
852
|
+
const permissions = permissionsOf(profile);
|
|
853
|
+
const allow = permissions['allow'];
|
|
854
|
+
if (!Array.isArray(allow))
|
|
855
|
+
throw internal('the permission-profile template has no `permissions.allow` list to add to');
|
|
856
|
+
for (const entry of entries) {
|
|
857
|
+
if (!allow.includes(entry))
|
|
858
|
+
allow.push(entry);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* One `allow` entry per `parity.toolchainCommands` value, each an exact literal under the directory
|
|
863
|
+
* `init --reference-toolchain-path` named — and **nothing at all** when the flag is absent or the
|
|
864
|
+
* parity phase is off.
|
|
865
|
+
*
|
|
866
|
+
* The phase gate is the one `docs/config.md` §4 puts on every parity input: the reference toolchain
|
|
867
|
+
* is read only when that phase runs, so allow-listing a machine-local directory outside the checkout
|
|
868
|
+
* for a repository that never compares against one would be granting a capability nothing uses. Each
|
|
869
|
+
* of the three ways this can come out empty warns rather than failing, because each has a different
|
|
870
|
+
* repository-side fix and none of them makes the rest of the profile wrong.
|
|
871
|
+
*/
|
|
872
|
+
function appendToolchainAllowances(profile, { config, referenceToolchainPath, warn }) {
|
|
873
|
+
const directory = referenceToolchainPath?.trim();
|
|
874
|
+
if (config.phases?.parity !== true) {
|
|
875
|
+
if (directory !== undefined && directory !== '') {
|
|
876
|
+
warn(`${TOOLCHAIN_FLAG} was given but phases.parity is off, so no reference-toolchain entry was written: the path is read only when the parity phase runs`);
|
|
877
|
+
}
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (directory === undefined || directory === '') {
|
|
881
|
+
warn(`the parity phase is on and no ${TOOLCHAIN_FLAG} was given, so the reference implementation's own toolchain is not allow-listed: it usually sits outside the checkout, where no other entry in the permission profile reaches it. Re-run init with ${TOOLCHAIN_FLAG} <absolute path> to add one entry per parity.toolchainCommands value`);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!isAbsolute(directory)) {
|
|
885
|
+
throw new HarnessError(`${TOOLCHAIN_FLAG} must be an absolute path, and ${JSON.stringify(directory)} is not: the entries built from it are matched literally against the command a caller runs, and a relative path names a different directory for every caller`);
|
|
886
|
+
}
|
|
887
|
+
assertUsableInEntry(directory, `${TOOLCHAIN_FLAG} ${JSON.stringify(directory)}`);
|
|
888
|
+
const commands = (config.parity?.toolchainCommands ?? []).map((command) => command.trim()).filter((c) => c !== '');
|
|
889
|
+
if (commands.length === 0) {
|
|
890
|
+
warn(`${TOOLCHAIN_FLAG} was given but parity.toolchainCommands lists nothing, so no reference-toolchain entry was written: name the reference implementation's own commands there - its static analysis, its formatter - and re-run init`);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const entries = commands.map((command) => {
|
|
894
|
+
assertUsableInEntry(command, `the parity.toolchainCommands entry ${JSON.stringify(command)}`);
|
|
895
|
+
return `Bash(${join(directory, command)}:*)`;
|
|
896
|
+
});
|
|
897
|
+
appendAllow(profile, entries);
|
|
898
|
+
appendReadme(profile, `THE REFERENCE TOOLCHAIN IS ALLOW-LISTED UNDER ONE ABSOLUTE PATH: the entries naming ${directory} were built from init's ${TOOLCHAIN_FLAG} flag and the parity.toolchainCommands list, because the implementation the parity phase compares against usually sits outside this checkout, where nothing else in this file reaches it. That directory is machine-local rather than repository-scoped, so it is the entry a clone on another machine is most likely to have to correct.`);
|
|
899
|
+
}
|
|
900
|
+
/** The command that re-derives this machine's plugin roots and prints the lines to paste. */
|
|
901
|
+
const DOCTOR_COMMAND = 'npx autonomous-sdlc-harness doctor';
|
|
902
|
+
/**
|
|
903
|
+
* The plugin roots this machine resolves, **runtime root first** — `doctor`'s own order — with the
|
|
904
|
+
* `undefined` of a machine that records neither removed and the ordinary machine's two spellings of
|
|
905
|
+
* one directory collapsed to one. Each is re-derived here per call, per `machine/plugins.ts`'s rule.
|
|
906
|
+
*/
|
|
907
|
+
function resolvedPluginRoots(repoRoot) {
|
|
908
|
+
const roots = [pluginRuntimeRoot(repoRoot), pluginInstallRoot(repoRoot)];
|
|
909
|
+
return [...new Set(roots.filter((root) => root !== undefined).map(normalizedRoot))];
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* The `permissions.allow` entries of the profile currently on disk, or **an empty list for every way
|
|
913
|
+
* that can fail** — absent, unreadable, not JSON, not an object, no `permissions`, no `allow`.
|
|
914
|
+
*
|
|
915
|
+
* This is a preservation courtesy on top of a `.bak` the write engine takes anyway, so it may never
|
|
916
|
+
* turn a working `init` into a refusal; an element that is not a string is skipped for the same
|
|
917
|
+
* reason rather than discarding the ones beside it.
|
|
918
|
+
*/
|
|
919
|
+
function existingAllowEntries(repoRoot) {
|
|
920
|
+
let parsed;
|
|
921
|
+
try {
|
|
922
|
+
parsed = readJsonFile(join(repoRoot, PROFILE_PATH));
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
return [];
|
|
926
|
+
}
|
|
927
|
+
if (!isJsonObject(parsed))
|
|
928
|
+
return [];
|
|
929
|
+
const permissions = parsed['permissions'];
|
|
930
|
+
if (!isJsonObject(permissions))
|
|
931
|
+
return [];
|
|
932
|
+
const allow = permissions['allow'];
|
|
933
|
+
if (!Array.isArray(allow))
|
|
934
|
+
return [];
|
|
935
|
+
return allow.filter((entry) => typeof entry === 'string');
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Read the profile `--force` is about to replace, and split its absolute-directory `allow` entries
|
|
939
|
+
* three ways: the ones a root on this machine still answers for, the helper-form ones nothing
|
|
940
|
+
* answers for, and every other absolute entry this generator does not preserve.
|
|
941
|
+
*
|
|
942
|
+
* **The two left-behind lists are separate because only one of them has a cause and a remedy.** A
|
|
943
|
+
* helper-form entry under no resolved root is what a plugin upgrade strands, and `doctor` re-derives
|
|
944
|
+
* the replacement; every other absolute entry is one this generator never produced and does not
|
|
945
|
+
* preserve, and `doctor` is silent on it by design, so a single list would bill an adopter's
|
|
946
|
+
* reference-implementation `Read` to an upgrade that did not touch it and send them to a report that
|
|
947
|
+
* cannot mention it.
|
|
948
|
+
*
|
|
949
|
+
* **The under-a-resolved-root test is load-bearing and may never be relaxed to "defined".**
|
|
950
|
+
* {@link pluginRootEntryTarget}'s stated contract is that a wrapper's repo-root-absolute form —
|
|
951
|
+
* `Bash(bash <repoRoot>/scripts/test.sh:*)`, `Read(/<repoRoot>/**)` — is byte-shaped identically to a
|
|
952
|
+
* plugin-helper entry and answers `<repoRoot>`; this test is what keeps those out of both left-behind
|
|
953
|
+
* lists, since the generator produces them itself and would otherwise report its own output as an
|
|
954
|
+
* adopter's hand-added line.
|
|
955
|
+
*
|
|
956
|
+
* **Graded nothing, therefore revoke nothing.** Where no root resolved — the module header's own
|
|
957
|
+
* ordinary case, `init` running before the plugin is enabled, plus a shell resolving another
|
|
958
|
+
* `CLAUDE_CONFIG_DIR`/`HOME` and a record momentarily unreadable — every absolute entry outside this
|
|
959
|
+
* checkout is carried forward unverified and both left-behind lists stay empty. Neither of their
|
|
960
|
+
* messages was earned: one blames an upgrade this run observed nothing about, the other claims
|
|
961
|
+
* nothing re-derives an entry `doctor` re-derives on every run where a root answers. Preserving is
|
|
962
|
+
* the only act the run has evidence for, and it is the direction {@link existingAllowEntries}'s
|
|
963
|
+
* courtesy contract already points — it may never turn a working `init` into a revocation.
|
|
964
|
+
*
|
|
965
|
+
* **Branch order is load-bearing in two directions.** The under-a-resolved-root test stays first: a
|
|
966
|
+
* `directory`-sourced marketplace can resolve a runtime root that lives *inside* the checkout (this
|
|
967
|
+
* repository's own `plugin/` is such a source), so hoisting the `here` skip above it would stop
|
|
968
|
+
* carrying that machine's entries. The zero-root branch sits *after* the `here` skip, so this
|
|
969
|
+
* generator's own output is still reported on by neither list.
|
|
970
|
+
*/
|
|
971
|
+
function carriedPluginRootEntries({ repoRoot }) {
|
|
972
|
+
const roots = resolvedPluginRoots(repoRoot);
|
|
973
|
+
const rootsResolved = roots.length > 0;
|
|
974
|
+
const here = normalizedRoot(repoRoot);
|
|
975
|
+
const carried = [];
|
|
976
|
+
const staleHelpers = [];
|
|
977
|
+
const notPreserved = [];
|
|
978
|
+
for (const entry of existingAllowEntries(repoRoot)) {
|
|
979
|
+
const target = pluginRootEntryTarget(entry);
|
|
980
|
+
if (target === undefined)
|
|
981
|
+
continue;
|
|
982
|
+
if (roots.some((root) => isUnderDirectory(target, root)))
|
|
983
|
+
carried.push(entry);
|
|
984
|
+
else if (isUnderDirectory(target, here))
|
|
985
|
+
continue; // This generator's own output — reported on by neither.
|
|
986
|
+
// Nothing resolved, so nothing about this entry is knowable: neither left-behind message's cause
|
|
987
|
+
// was observed and neither one's remedy applies. Unreachable on the resolved arm, so the
|
|
988
|
+
// insertion changes nothing there.
|
|
989
|
+
else if (!rootsResolved)
|
|
990
|
+
carried.push(entry);
|
|
991
|
+
else if (bashScriptRuleTarget(entry) !== undefined)
|
|
992
|
+
staleHelpers.push(entry);
|
|
993
|
+
else
|
|
994
|
+
notPreserved.push(entry);
|
|
995
|
+
}
|
|
996
|
+
return { carried, staleHelpers, notPreserved, rootsResolved };
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Carry the profile's resolved-plugin-root entries into the one replacing it, and say what was
|
|
1000
|
+
* carried and what was left behind.
|
|
1001
|
+
*
|
|
1002
|
+
* **Screened before appended.** Every carried entry passes the shapes {@link assertRunnable}
|
|
1003
|
+
* enforces first, and one that fails is warned about rather than carried: these are bytes an
|
|
1004
|
+
* *adopter* typed, and that check exits {@link EXIT.INTERNAL} on the stated ground that every failure
|
|
1005
|
+
* arriving there is one the adopter could not have caused.
|
|
1006
|
+
*
|
|
1007
|
+
* **Every sentence is conditioned on `rootsResolved`, the screening warnings included.** Where no
|
|
1008
|
+
* root resolved this run graded nothing, so it may not open a warning with "names a plugin root this
|
|
1009
|
+
* machine resolves" or a note with "naming this machine's plugin roots" — an assertion it did not
|
|
1010
|
+
* make. Each has a second wording claiming only what was observed, and each keeps its remedy.
|
|
1011
|
+
*/
|
|
1012
|
+
function appendCarriedPluginRootEntries(profile, { repoRoot, dryRun, warn, note }) {
|
|
1013
|
+
const { carried, staleHelpers, notPreserved, rootsResolved } = carriedPluginRootEntries({ repoRoot });
|
|
1014
|
+
const already = new Set(entriesOf(permissionsOf(profile), 'allow'));
|
|
1015
|
+
const usable = [];
|
|
1016
|
+
// What the screening warnings below may claim about an entry's directory. Spelled once, because
|
|
1017
|
+
// the two warnings differ only in the defect they go on to name.
|
|
1018
|
+
const names = rootsResolved
|
|
1019
|
+
? 'names a plugin root this machine resolves but'
|
|
1020
|
+
: 'names an absolute directory this machine could not grade, and';
|
|
1021
|
+
for (const entry of carried) {
|
|
1022
|
+
if (already.has(entry))
|
|
1023
|
+
continue;
|
|
1024
|
+
const forbidden = FORBIDDEN_IN_ENTRY.find(([needle]) => entry.includes(needle));
|
|
1025
|
+
if (forbidden !== undefined) {
|
|
1026
|
+
warn(`the ${PROFILE_PATH} entry ${JSON.stringify(entry)} ${names} contains ${forbidden[1]}, and the permission guard matches an entry literally: it is not carried into the regenerated profile, because an entry carrying that character matches nothing at run time. Re-add it by hand in a form that carries none, or run \`${DOCTOR_COMMAND}\` for the exact line`);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
if (containsToken(entry)) {
|
|
1030
|
+
warn(`the ${PROFILE_PATH} entry ${JSON.stringify(entry)} ${names} still carries an unsubstituted \`{{token}}\`, so it matches nothing at run time and is not carried into the regenerated profile: run \`${DOCTOR_COMMAND}\` for the exact line to paste`);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
usable.push(entry);
|
|
1034
|
+
}
|
|
1035
|
+
if (usable.length > 0) {
|
|
1036
|
+
appendAllow(profile, usable);
|
|
1037
|
+
note(rootsResolved
|
|
1038
|
+
? `${usable.length} \`permissions.allow\` ${entryWord(usable.length)} naming this machine's plugin roots ${dryRun ? 'would be carried' : 'were carried'} forward into the regenerated ${PROFILE_PATH}: those are the lines \`doctor\`'s plugin-permissions check dictates and \`init\` does not generate, so --force ${dryRun ? 'would leave' : 'left'} them in place rather than revoking a permission set that was pasted in by hand`
|
|
1039
|
+
: `${usable.length} \`permissions.allow\` ${entryWord(usable.length)} naming an absolute directory outside this checkout ${dryRun ? 'would be carried' : 'were carried'} forward into the regenerated ${PROFILE_PATH} unverified: ${installedPluginsPath()} records no install root for this plugin on this machine, so nothing here could tell an entry \`doctor\` dictated from one a plugin upgrade stranded, and revoking a permission set that was pasted in by hand is the worse of the two errors. Enable the plugin and run \`${DOCTOR_COMMAND}\`, which re-derives the roots and names any entry that has gone stale`);
|
|
1040
|
+
}
|
|
1041
|
+
// Tense from `dryRun` for the same reason the note above takes it: a preview writes nothing, so it
|
|
1042
|
+
// may not report a drop in the past tense (`docs/cli.md` §1). Verb from the same count the sentence
|
|
1043
|
+
// already spells, so the noun and the verb beside it agree.
|
|
1044
|
+
if (staleHelpers.length > 0) {
|
|
1045
|
+
const one = staleHelpers.length === 1;
|
|
1046
|
+
const dropped = `${one ? 'it' : 'they'} ${dryRun ? 'would not be' : one ? 'was not' : 'were not'}`;
|
|
1047
|
+
warn(`${staleHelpers.length} \`permissions.allow\` ${entryWord(staleHelpers.length)} in ${PROFILE_PATH} ${one ? 'names' : 'name'} an absolute directory that is neither this checkout nor a plugin root this machine resolves, so ${dropped} carried into the regenerated profile: a plugin upgrade moves the version-carrying install root, which leaves the entries written for the old one naming a directory nothing resolves. Run \`${DOCTOR_COMMAND}\`, which re-derives this machine's roots and prints the lines to paste. Left behind:\n${staleHelpers.join('\n')}`);
|
|
1048
|
+
}
|
|
1049
|
+
// No cause claimed and no remedy named: these are entries no root on this machine ever answered
|
|
1050
|
+
// for, so nothing re-derives them and `doctor` — which passes over exactly this form — would send
|
|
1051
|
+
// the adopter through a report that cannot mention the entry they came to it about.
|
|
1052
|
+
if (notPreserved.length > 0) {
|
|
1053
|
+
const one = notPreserved.length === 1;
|
|
1054
|
+
const absent = `${one ? 'it' : 'they'} ${dryRun ? 'would not be' : one ? 'is not' : 'are not'} in`;
|
|
1055
|
+
const object = one ? 'it' : 'them';
|
|
1056
|
+
warn(`${notPreserved.length} \`permissions.allow\` ${entryWord(notPreserved.length)} in ${PROFILE_PATH} ${one ? 'names' : 'name'} an absolute directory outside this checkout that this generator does not produce and does not preserve — a grant over a reference implementation or another machine-local toolchain is the usual case — so ${absent} the regenerated profile. Nothing re-derives ${object}: add ${object} back by hand. Left behind:\n${notPreserved.join('\n')}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Render the permission profile for one repository: the template, with every token resolved from
|
|
1061
|
+
* the runtime paths and the config in effect, and one group of three `allow` entries per wrapper
|
|
1062
|
+
* script that was written.
|
|
1063
|
+
*
|
|
1064
|
+
* Nothing here reads a path out of a file that ships and nothing here touches the filesystem beyond
|
|
1065
|
+
* reading the template: `<repo_root>`, `<work_root>` and `<worktree_glob>` are resolved per run
|
|
1066
|
+
* (`docs/config.md` §4), which is the whole reason the profile is generated rather than copied.
|
|
1067
|
+
*/
|
|
1068
|
+
export function renderProfile({ repoRoot, config, workRoot, written, writtenOuterLoop, referenceToolchainPath, force = false, dryRun = false, warn, note, }) {
|
|
1069
|
+
const scriptsDir = normalizeRepoDir(config.scriptsDir ?? DEFAULTS.scriptsDir);
|
|
1070
|
+
const stateDir = normalizeRepoDir(config.stateDir ?? DEFAULTS.stateDir);
|
|
1071
|
+
const projectName = config.projectName ?? defaultProjectName(repoRoot);
|
|
1072
|
+
// Before substitution, because after it these are indistinguishable from what the template wrote
|
|
1073
|
+
// and `assertRunnable` would bill the adopter's own directory name to this CLI (module header).
|
|
1074
|
+
assertRepositoryValuesUsable({ repoRoot, projectName, scriptsDir, stateDir });
|
|
1075
|
+
const globals = {
|
|
1076
|
+
repoRoot,
|
|
1077
|
+
workRoot,
|
|
1078
|
+
worktreeGlob: worktreeGlob(workRoot, projectName),
|
|
1079
|
+
scriptsDir,
|
|
1080
|
+
scriptsDirAbs: join(repoRoot, scriptsDir),
|
|
1081
|
+
stateDirAbs: join(repoRoot, stateDir),
|
|
1082
|
+
agentModel: config.agentModel ?? DEFAULTS.agentModel,
|
|
1083
|
+
};
|
|
1084
|
+
const scripts = written === undefined ? selectWrapperScripts(config) : fromWritten(written);
|
|
1085
|
+
const outerLoop = writtenOuterLoop === undefined ? selectOuterLoopScripts(config) : fromWrittenOuterLoop(writtenOuterLoop);
|
|
1086
|
+
const families = [
|
|
1087
|
+
rowFamily(WRAPPER_ROW_TOKENS, scripts),
|
|
1088
|
+
rowFamily(DEPLOY_ROW_TOKENS, scripts.filter((script) => script.key === 'deploy')),
|
|
1089
|
+
rowFamily(OUTER_LOOP_ROW_TOKENS, outerLoop),
|
|
1090
|
+
];
|
|
1091
|
+
const known = new Set([
|
|
1092
|
+
...Object.keys(globals),
|
|
1093
|
+
...tokenNames(WRAPPER_ROW_TOKENS),
|
|
1094
|
+
...tokenNames(DEPLOY_ROW_TOKENS),
|
|
1095
|
+
...tokenNames(OUTER_LOOP_ROW_TOKENS),
|
|
1096
|
+
]);
|
|
1097
|
+
const profile = renderValue(readTemplateObject(TEMPLATE_PATH), globals, families, known);
|
|
1098
|
+
// The browser half, and only when the phase that drives a browser is on **and** its driver is the
|
|
1099
|
+
// browser one — the same predicate the repository's own MCP wiring is gated on, imported rather
|
|
1100
|
+
// than restated so the two sides cannot answer the question differently. The fragment is rendered
|
|
1101
|
+
// through the same substitution as the base, so a token added to it later resolves — or is caught
|
|
1102
|
+
// as an unknown one — rather than shipping unsubstituted.
|
|
1103
|
+
if (browserWiringApplies(config)) {
|
|
1104
|
+
const fragment = renderValue(readTemplateObject(QA_TEMPLATE_PATH), globals, families, known);
|
|
1105
|
+
mergeInto(profile, fragment, '');
|
|
1106
|
+
}
|
|
1107
|
+
// Before the toolchain entries and after the fragment, which is the window in which every `.sh`
|
|
1108
|
+
// entry in the profile came from a wrapper or outer-loop row: a toolchain command that happened
|
|
1109
|
+
// to be named like one of them would otherwise be counted as a fourth form of it.
|
|
1110
|
+
assertEveryScriptListed(profile, [...scripts, ...outerLoop]);
|
|
1111
|
+
appendToolchainAllowances(profile, {
|
|
1112
|
+
config,
|
|
1113
|
+
...(referenceToolchainPath === undefined ? {} : { referenceToolchainPath }),
|
|
1114
|
+
warn: warn ?? (() => { }),
|
|
1115
|
+
});
|
|
1116
|
+
// In the same window and for the same reason: a carried plugin helper whose basename happens to
|
|
1117
|
+
// match a wrapper's would be counted as a fourth form of that wrapper above. Gated on `force`,
|
|
1118
|
+
// which is the only run that replaces the file these entries are read out of — an unforced render
|
|
1119
|
+
// reads nothing and produces exactly what it did before this step existed.
|
|
1120
|
+
if (force) {
|
|
1121
|
+
appendCarriedPluginRootEntries(profile, {
|
|
1122
|
+
repoRoot,
|
|
1123
|
+
dryRun,
|
|
1124
|
+
warn: warn ?? (() => { }),
|
|
1125
|
+
note: note ?? (() => { }),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
assertRunnable(profile);
|
|
1129
|
+
assertNoBrowserDeny(profile);
|
|
1130
|
+
assertBrowserWiring(profile);
|
|
1131
|
+
return profile;
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Render the profile and enqueue it at {@link PROFILE_PATH} under the `create-if-absent` contract.
|
|
1135
|
+
*
|
|
1136
|
+
* That contract is the whole reason this is a separate step from rendering. The profile is
|
|
1137
|
+
* **hand-tuned after generation** — the entry an adopter adds to close a stall is the most valuable
|
|
1138
|
+
* line in the file and the least reproducible — so a second `init` leaves an existing one
|
|
1139
|
+
* byte-identical, and `--force` regenerates it only after the write engine has copied it to a `.bak`
|
|
1140
|
+
* sibling — carrying the plugin-root entries of the file it replaces into the one it writes, which is
|
|
1141
|
+
* the one group in that file this generator knowingly cannot produce ({@link renderProfile}).
|
|
1142
|
+
*
|
|
1143
|
+
* Nothing here touches the filesystem: the generator plans, and `init` applies the plan once.
|
|
1144
|
+
*/
|
|
1145
|
+
export function writePermissionProfile({ repoRoot, config, plan, workRoot, written, writtenOuterLoop, referenceToolchainPath, force, dryRun, }) {
|
|
1146
|
+
const warnings = [];
|
|
1147
|
+
const notes = [];
|
|
1148
|
+
const profile = renderProfile({
|
|
1149
|
+
repoRoot,
|
|
1150
|
+
config,
|
|
1151
|
+
workRoot: workRoot ?? resolveWorkRoot(repoRoot),
|
|
1152
|
+
...(written === undefined ? {} : { written }),
|
|
1153
|
+
...(writtenOuterLoop === undefined ? {} : { writtenOuterLoop }),
|
|
1154
|
+
...(referenceToolchainPath === undefined ? {} : { referenceToolchainPath }),
|
|
1155
|
+
...(force === undefined ? {} : { force }),
|
|
1156
|
+
...(dryRun === undefined ? {} : { dryRun }),
|
|
1157
|
+
warn: (message) => warnings.push(message),
|
|
1158
|
+
note: (message) => notes.push(message),
|
|
1159
|
+
});
|
|
1160
|
+
const path = join(repoRoot, PROFILE_PATH);
|
|
1161
|
+
plan.add({ path, policy: 'create-if-absent', content: profile, label: 'unattended permission profile' });
|
|
1162
|
+
return {
|
|
1163
|
+
path,
|
|
1164
|
+
repoPath: PROFILE_PATH,
|
|
1165
|
+
warnings,
|
|
1166
|
+
notes: [
|
|
1167
|
+
`${PROFILE_PATH} is committed, and it is machine-specific: the absolute paths in it are this checkout's, so a copy of this repository somewhere else needs doctor to re-check them and init --force to regenerate them. Select it per run with the agent runner's settings flag; it is never installed as the interactive default.`,
|
|
1168
|
+
...notes,
|
|
1169
|
+
],
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
//# sourceMappingURL=permissionProfile.js.map
|