dorfl 0.14.1 → 0.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/advance-ci-template.d.ts.map +1 -1
  2. package/dist/advance-ci-template.js +10 -4
  3. package/dist/advance-ci-template.js.map +1 -1
  4. package/dist/advance-lifecycle-template.d.ts.map +1 -1
  5. package/dist/advance-lifecycle-template.js +78 -25
  6. package/dist/advance-lifecycle-template.js.map +1 -1
  7. package/dist/cli.js +2 -2
  8. package/dist/close-job-template.d.ts +1 -1
  9. package/dist/close-job-template.d.ts.map +1 -1
  10. package/dist/close-job-template.js +17 -3
  11. package/dist/close-job-template.js.map +1 -1
  12. package/dist/install-ci-action-pins.d.ts +98 -0
  13. package/dist/install-ci-action-pins.d.ts.map +1 -0
  14. package/dist/install-ci-action-pins.js +165 -0
  15. package/dist/install-ci-action-pins.js.map +1 -0
  16. package/dist/install-ci-core.d.ts +84 -0
  17. package/dist/install-ci-core.d.ts.map +1 -1
  18. package/dist/install-ci-core.js +120 -14
  19. package/dist/install-ci-core.js.map +1 -1
  20. package/dist/install-ci-github.d.ts +15 -1
  21. package/dist/install-ci-github.d.ts.map +1 -1
  22. package/dist/install-ci-github.js +32 -0
  23. package/dist/install-ci-github.js.map +1 -1
  24. package/dist/install-ci.d.ts.map +1 -1
  25. package/dist/install-ci.js +21 -0
  26. package/dist/install-ci.js.map +1 -1
  27. package/dist/intake-trigger-template.d.ts.map +1 -1
  28. package/dist/intake-trigger-template.js +24 -7
  29. package/dist/intake-trigger-template.js.map +1 -1
  30. package/dist/intake.d.ts.map +1 -1
  31. package/dist/intake.js +10 -5
  32. package/dist/intake.js.map +1 -1
  33. package/dist/mint-adr.d.ts.map +1 -1
  34. package/dist/mint-adr.js +4 -1
  35. package/dist/mint-adr.js.map +1 -1
  36. package/dist/slug-namespace.d.ts +4 -3
  37. package/dist/slug-namespace.d.ts.map +1 -1
  38. package/dist/slug-namespace.js +28 -3
  39. package/dist/slug-namespace.js.map +1 -1
  40. package/dist/slug-safety.d.ts +55 -0
  41. package/dist/slug-safety.d.ts.map +1 -0
  42. package/dist/slug-safety.js +76 -0
  43. package/dist/slug-safety.js.map +1 -0
  44. package/dist/triage-persist.d.ts.map +1 -1
  45. package/dist/triage-persist.js +12 -3
  46. package/dist/triage-persist.js.map +1 -1
  47. package/dist/verify-workflow-template.d.ts +1 -1
  48. package/dist/verify-workflow-template.d.ts.map +1 -1
  49. package/dist/verify-workflow-template.js +16 -3
  50. package/dist/verify-workflow-template.js.map +1 -1
  51. package/package.json +3 -2
  52. package/src/advance-ci-template.ts +12 -4
  53. package/src/advance-lifecycle-template.ts +80 -25
  54. package/src/cli.ts +2 -2
  55. package/src/close-job-template.ts +17 -3
  56. package/src/install-ci-action-pins.ts +184 -0
  57. package/src/install-ci-core.ts +166 -14
  58. package/src/install-ci-github.ts +40 -1
  59. package/src/install-ci.ts +25 -0
  60. package/src/intake-trigger-template.ts +28 -7
  61. package/src/intake.ts +10 -5
  62. package/src/mint-adr.ts +4 -1
  63. package/src/slug-namespace.ts +33 -5
  64. package/src/slug-safety.ts +83 -0
  65. package/src/triage-persist.ts +13 -7
  66. package/src/verify-workflow-template.ts +16 -3
@@ -25,10 +25,21 @@
25
25
  * `DORFL_*` env block, NOT a new config knob minted here.
26
26
  */
27
27
 
28
- import {readFileSync, writeFileSync, mkdirSync, readdirSync} from 'node:fs';
28
+ import {
29
+ readFileSync,
30
+ writeFileSync,
31
+ mkdirSync,
32
+ readdirSync,
33
+ existsSync,
34
+ } from 'node:fs';
29
35
  import {dirname, join, relative} from 'node:path';
30
36
  import {fileURLToPath} from 'node:url';
31
37
  import type {HarnessAdapter} from './config.js';
38
+ import {
39
+ ACTION_PINS,
40
+ pinnedUses,
41
+ preserveExistingPins,
42
+ } from './install-ci-action-pins.js';
32
43
 
33
44
  // ─── Config model ──────────────────────────────────────────────────────────
34
45
 
@@ -141,6 +152,44 @@ export interface ResolvedCIConfig {
141
152
  projectSetup?: Record<string, unknown>;
142
153
  /** See {@link CIConfigFile.maxParallel}. Always resolved (default applied). */
143
154
  maxParallel: number;
155
+ /**
156
+ * The repo's visibility, DETECTED at generation time through the provider
157
+ * seam ({@link CIProviderContext.getRepoVisibility}); never read from or
158
+ * written to a config file (it is a fact about the repo, not a choice).
159
+ * `undefined` ⇒ unknown, which keeps the conservative output. See
160
+ * {@link shouldDropCheckoutCredentials}.
161
+ */
162
+ repoVisibility?: RepoVisibility;
163
+ }
164
+
165
+ /**
166
+ * A repository's visibility on its CI provider. Used ONLY to decide whether the
167
+ * read-only jobs (verify, close-job) may check out with
168
+ * `persist-credentials: false`: on a PUBLIC repo every git read works without a
169
+ * token, so dropping the persisted token cannot break a fetch; on a private or
170
+ * internal repo (or when visibility is unknown) a fetch may need it.
171
+ */
172
+ export type RepoVisibility = 'public' | 'private' | 'internal';
173
+
174
+ /** Parse a provider's visibility answer (`PUBLIC`, `private`, ...); unknown ⇒ `undefined`. */
175
+ export function parseRepoVisibility(raw: string): RepoVisibility | undefined {
176
+ const v = raw.trim().toLowerCase();
177
+ return v === 'public' || v === 'private' || v === 'internal' ? v : undefined;
178
+ }
179
+
180
+ /**
181
+ * Whether the read-only jobs (verify, close-job) should check out with
182
+ * `persist-credentials: false`. True ONLY when the repo is KNOWN to be public:
183
+ * then the persisted token adds nothing a git read needs (those jobs have
184
+ * `contents: read`, so they cannot push either way), while leaving it in
185
+ * `.git/config` hands it to every later step, including the project-setup
186
+ * hook's dependency installs. Unknown or non-public ⇒ keep today's checkout, so
187
+ * a project gate that runs `git fetch` on a private repo keeps working.
188
+ */
189
+ export function shouldDropCheckoutCredentials(
190
+ config: Pick<ResolvedCIConfig, 'repoVisibility'>,
191
+ ): boolean {
192
+ return config.repoVisibility === 'public';
144
193
  }
145
194
 
146
195
  /** The default harness the composite setup action installs. */
@@ -222,6 +271,13 @@ export interface CIProviderContext {
222
271
  * no default-branch concept.
223
272
  */
224
273
  getDefaultBranch?(): Promise<string | undefined>;
274
+ /**
275
+ * OPTIONALLY report the repo's VISIBILITY (GitHub:
276
+ * `gh repo view --json visibility`). Read-only. Returns `undefined` when the
277
+ * lookup fails; absent on a provider with no such concept. Drives
278
+ * {@link shouldDropCheckoutCredentials}.
279
+ */
280
+ getRepoVisibility?(): Promise<RepoVisibility | undefined>;
225
281
  /**
226
282
  * OPTIONALLY create/replace a branch RULESET (GitHub:
227
283
  * `POST /repos/{owner}/{repo}/rulesets`) carrying the deadlock-guard
@@ -673,6 +729,75 @@ function indent(text: string, spaces: number): string {
673
729
  .join('\n');
674
730
  }
675
731
 
732
+ /**
733
+ * The npm package the composite setup action installs for the `pi` harness.
734
+ *
735
+ * Upstream moved the harness from `@mariozechner/pi-coding-agent` (deprecated,
736
+ * frozen at 0.73.1) to this name. The binary is still `pi` and it still reads
737
+ * `~/.pi/agent/` (models.json / auth.json), so nothing else in the generated
738
+ * action changes.
739
+ */
740
+ export const PI_HARNESS_PACKAGE = '@earendil-works/pi-coding-agent';
741
+
742
+ /**
743
+ * The EXACT `pi` harness version the composite setup action installs.
744
+ *
745
+ * Pinned because the install runs in jobs that hold `contents: write` and a
746
+ * provider API key: an unpinned `npm install -g` resolves whatever `latest` is
747
+ * at RUN time, so a compromised or broken release would reach those
748
+ * credentials with no review and no lockfile to catch it.
749
+ *
750
+ * It is a constant dorfl DECLARES (not an install-ci parameter) because the
751
+ * harness is coupled to dorfl, not to the consumer: dorfl drives `pi` through
752
+ * its CLI flags and reads its session files (`session-path.ts`,
753
+ * `watch-session.ts`), so the harness version that works is a property of the
754
+ * dorfl release. Bump it here, with a changeset, when a dorfl release needs a
755
+ * newer harness; every consumer picks it up on their next `install-ci` run.
756
+ *
757
+ * 0.80.6 is the version dorfl is exercised against day to day (the maintainer's
758
+ * installed `pi`), not the newest release: a harness upgrade should be a
759
+ * deliberate, tested bump. It requires Node >= 22.19.0, which the action's
760
+ * `node-version: '22'` satisfies (setup-node resolves the latest 22.x).
761
+ */
762
+ export const PI_HARNESS_VERSION = '0.80.6';
763
+
764
+ /**
765
+ * The version of THIS dorfl package, read at runtime from its own
766
+ * `package.json` (the single source changesets bumps on release). The composite
767
+ * setup action installs `dorfl@<this version>` so a consumer's CI runs the SAME
768
+ * CLI that generated its workflows, instead of whatever `latest` is at run time.
769
+ *
770
+ * Resolved relative to this module (`dist/install-ci-core.js` or
771
+ * `src/install-ci-core.ts` → `../package.json`), so it works from the built CLI
772
+ * and under tsx. Throws when the version cannot be read: silently falling back
773
+ * to an unpinned install would reintroduce exactly the risk the pin removes.
774
+ */
775
+ export function dorflPackageVersion(): string {
776
+ const here = dirname(fileURLToPath(import.meta.url));
777
+ const pkgPath = join(here, '..', 'package.json');
778
+ let version: unknown;
779
+ try {
780
+ version = (JSON.parse(readFileSync(pkgPath, 'utf8')) as {version?: unknown})
781
+ .version;
782
+ } catch (err) {
783
+ throw new Error(
784
+ `install-ci: cannot read dorfl's own version from ${pkgPath} ` +
785
+ `(needed to pin \`npm install -g dorfl@<version>\`): ` +
786
+ (err instanceof Error ? err.message : String(err)),
787
+ );
788
+ }
789
+ if (
790
+ typeof version !== 'string' ||
791
+ !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(version)
792
+ ) {
793
+ throw new Error(
794
+ `install-ci: dorfl's package.json at ${pkgPath} has no usable semver ` +
795
+ `"version" (got ${JSON.stringify(version)}); cannot pin the CI install.`,
796
+ );
797
+ }
798
+ return version;
799
+ }
800
+
676
801
  /**
677
802
  * The install step for the configured harness (the `pi` CLI; `''` ⇒ none).
678
803
  * `registry` mode installs via `npm install -g`; `workspace` mode installs via
@@ -684,11 +809,15 @@ function harnessInstallStep(
684
809
  installSource: InstallSource,
685
810
  ): string {
686
811
  if (harness === 'pi') {
812
+ const spec = `${PI_HARNESS_PACKAGE}@${PI_HARNESS_VERSION}`;
687
813
  const run =
688
814
  installSource === 'workspace'
689
- ? 'pnpm add -g @mariozechner/pi-coding-agent'
690
- : 'npm install -g @mariozechner/pi-coding-agent';
815
+ ? `pnpm add -g ${spec}`
816
+ : `npm install -g ${spec}`;
691
817
  return `
818
+ # Pinned to the exact harness version this dorfl release declares
819
+ # (PI_HARNESS_VERSION): the job holds write access and a provider key, so the
820
+ # version must not move on its own. Re-run \`dorfl install-ci\` to upgrade.
692
821
  - name: Install agent harness (pi)
693
822
  shell: bash
694
823
  run: ${run}`;
@@ -807,7 +936,7 @@ ${indent(modelsJsonStr, 8)}
807
936
  if (config.installSource === 'workspace') {
808
937
  installSteps = `\
809
938
  - name: Setup pnpm
810
- uses: pnpm/action-setup@v5
939
+ uses: ${pinnedUses(ACTION_PINS.pnpmSetup)}
811
940
 
812
941
  - name: Add pnpm global bin to PATH
813
942
  shell: bash
@@ -815,17 +944,25 @@ ${indent(modelsJsonStr, 8)}
815
944
  pnpm setup
816
945
  echo "$HOME/.local/share/pnpm" >> "$GITHUB_PATH"
817
946
 
947
+ # \`--ignore-scripts\`: nothing here needs an install-time script. The build is
948
+ # explicit (\`pnpm -r build\` below; the root \`prepare\` would only repeat it),
949
+ # and verify runs on fork pull requests, where a lifecycle script would run
950
+ # the fork's code before any gate does.
818
951
  - name: Install dependencies and build dorfl
819
952
  shell: bash
820
953
  run: |
821
- pnpm install
954
+ pnpm install --ignore-scripts
822
955
  pnpm -r build
823
956
  cd packages/dorfl && pnpm link --global${installHarness}`;
824
957
  } else {
825
958
  installSteps = `\
959
+ # Pinned to the dorfl version that generated this action, so CI runs the
960
+ # same CLI that wrote these workflows (and never an unreviewed \`latest\` in a
961
+ # job holding write access and a provider key). Re-run \`dorfl install-ci\`
962
+ # after upgrading dorfl to move the pin.
826
963
  - name: Install dorfl
827
964
  shell: bash
828
- run: npm install -g dorfl${installHarness}`;
965
+ run: npm install -g dorfl@${dorflPackageVersion()}${installHarness}`;
829
966
  }
830
967
 
831
968
  // NO bespoke CI resolver shim (task install-ci-shim-converges-on-dorfl-cmd,
@@ -895,15 +1032,16 @@ runs:
895
1032
  using: composite
896
1033
  steps:
897
1034
  ${projectSetupBlock} - name: Setup Node.js
898
- uses: actions/setup-node@v5
1035
+ uses: ${pinnedUses(ACTION_PINS.setupNode)}
899
1036
  with:
900
1037
  node-version: '22'
901
- # setup-node@v5 auto-enables package-manager caching, which probes the
902
- # project package manager DURING its own run when a lockfile is present,
903
- # before that manager is on PATH, so the probe crashes ("Unable to
904
- # locate executable file"). dorfl provisions ONLY its own runtime and
905
- # leaves the project package-manager cache to the project (toolchain
906
- # boundary), so disable it explicitly.
1038
+ # setup-node (v5 and later) auto-enables package-manager caching. In v5
1039
+ # it probed whatever manager package.json's packageManager named, during
1040
+ # its own run and before that manager was on PATH, and crashed ("Unable
1041
+ # to locate executable file"). Since v6 it only fires when package.json
1042
+ # declares npm, and then fails if there is no npm lockfile. Either way
1043
+ # it is the project's cache, not dorfl's: dorfl provisions ONLY its own
1044
+ # runtime (toolchain boundary), so disable it explicitly.
907
1045
  package-manager-cache: false
908
1046
 
909
1047
  - name: Configure git identity
@@ -1069,6 +1207,13 @@ export function outputBaseName(fake: boolean): string {
1069
1207
  * Write the artifacts under `<workDir>/<base>/` (base = `.fake` when `fake`, else
1070
1208
  * `.github`). Returns the repo-relative paths actually written. Pure filesystem
1071
1209
  * I/O — no secrets, no git. Creates parent dirs as needed.
1210
+ *
1211
+ * PIN PRESERVATION. Before writing a YAML artifact, any `uses:` of an action the
1212
+ * repository's EXISTING `.github/<path>` already pins to a full commit SHA keeps
1213
+ * that reference and its trailing comment (see {@link preserveExistingPins}),
1214
+ * so a Dependabot bump survives regeneration and a pin is never swapped for a
1215
+ * tag. The existing file is always read from `.github/`, also in `--fake` mode,
1216
+ * so the `.fake/` preview shows what a real run would write.
1072
1217
  */
1073
1218
  export function writeArtifacts(options: {
1074
1219
  workDir: string;
@@ -1079,8 +1224,15 @@ export function writeArtifacts(options: {
1079
1224
  const written: string[] = [];
1080
1225
  for (const file of options.files) {
1081
1226
  const abs = join(options.workDir, base, file.path);
1227
+ let content = file.content;
1228
+ if (/\.ya?ml$/.test(file.path)) {
1229
+ const current = join(options.workDir, outputBaseName(false), file.path);
1230
+ if (existsSync(current)) {
1231
+ content = preserveExistingPins(content, readFileSync(current, 'utf8'));
1232
+ }
1233
+ }
1082
1234
  mkdirSync(dirname(abs), {recursive: true});
1083
- writeFileSync(abs, file.content, 'utf8');
1235
+ writeFileSync(abs, content, 'utf8');
1084
1236
  written.push(relative(options.workDir, abs));
1085
1237
  }
1086
1238
  return written;
@@ -16,7 +16,11 @@
16
16
 
17
17
  import {run, type RunResult} from './git.js';
18
18
  import {DEFAULT_GH_BIN} from './github.js';
19
- import type {CIProviderContext} from './install-ci-core.js';
19
+ import {
20
+ parseRepoVisibility,
21
+ type CIProviderContext,
22
+ type RepoVisibility,
23
+ } from './install-ci-core.js';
20
24
 
21
25
  /**
22
26
  * The provider id this adapter exposes through {@link CIProviderContext.providerId}.
@@ -265,6 +269,32 @@ export class GitHubCIContext implements CIProviderContext {
265
269
  return name === '' ? undefined : name;
266
270
  }
267
271
 
272
+ /**
273
+ * Detect the repo's VISIBILITY via
274
+ * `gh repo view --json visibility --jq .visibility` (GitHub answers
275
+ * `PUBLIC` / `PRIVATE` / `INTERNAL`). Returns `undefined` when `gh` is
276
+ * unavailable / the repo is unknown / the call fails / the answer is not one
277
+ * of those three, so the caller keeps the conservative (credential-persisting)
278
+ * checkout. Read-only.
279
+ */
280
+ async getRepoVisibility(): Promise<RepoVisibility | undefined> {
281
+ if (!this.repo) {
282
+ return undefined;
283
+ }
284
+ const result = this.runGh([
285
+ 'repo',
286
+ 'view',
287
+ '--json',
288
+ 'visibility',
289
+ '--jq',
290
+ '.visibility',
291
+ ]);
292
+ if (result === undefined || result.status !== 0) {
293
+ return undefined;
294
+ }
295
+ return parseRepoVisibility(result.stdout);
296
+ }
297
+
268
298
  /**
269
299
  * Create the deadlock-guard RULESET via
270
300
  * `POST /repos/{owner}/{repo}/rulesets`. The `ruleset` body is JSON-piped on
@@ -388,6 +418,8 @@ export class MemoryCIProviderContext implements CIProviderContext {
388
418
  private readonly adminScope: boolean | undefined;
389
419
  /** What `getDefaultBranch()` should return (tests configure). */
390
420
  private readonly defaultBranch: string | undefined;
421
+ /** What `getRepoVisibility()` should return (tests configure). */
422
+ private readonly visibility: RepoVisibility | undefined;
391
423
  /** Whether `setBranchProtection` should throw (tests configure). */
392
424
  private readonly branchProtectionError: string | undefined;
393
425
  /** Whether `setBranchRuleset` should throw (tests configure). */
@@ -404,6 +436,8 @@ export class MemoryCIProviderContext implements CIProviderContext {
404
436
  * exercises its `main` fallback (models a `gh` that could not resolve it).
405
437
  */
406
438
  defaultBranch?: string;
439
+ /** Fixture repo visibility; `undefined` ⇒ unknown (lookup failed / no gh). */
440
+ visibility?: RepoVisibility;
407
441
  /** When set, `setBranchProtection` throws with this message (failure path). */
408
442
  branchProtectionError?: string;
409
443
  /** When set, `setBranchRuleset` throws with this message (failure path). */
@@ -421,6 +455,7 @@ export class MemoryCIProviderContext implements CIProviderContext {
421
455
  this.ghAvailable = options.ghAvailable ?? false;
422
456
  this.adminScope = options.adminScope;
423
457
  this.defaultBranch = options.defaultBranch;
458
+ this.visibility = options.visibility;
424
459
  this.branchProtectionError = options.branchProtectionError;
425
460
  this.branchRulesetError = options.branchRulesetError;
426
461
  this.providerId = options.providerId ?? GITHUB_PROVIDER_ID;
@@ -446,6 +481,10 @@ export class MemoryCIProviderContext implements CIProviderContext {
446
481
  return this.defaultBranch;
447
482
  }
448
483
 
484
+ async getRepoVisibility(): Promise<RepoVisibility | undefined> {
485
+ return this.visibility;
486
+ }
487
+
449
488
  async setBranchProtection(branch: string, spec: unknown): Promise<void> {
450
489
  if (this.branchProtectionError !== undefined) {
451
490
  throw new Error(this.branchProtectionError);
package/src/install-ci.ts CHANGED
@@ -193,6 +193,31 @@ export async function installCI(
193
193
  }
194
194
  }
195
195
 
196
+ // 2c. Detect the repo's VISIBILITY (read-only; also in --fake, like the repo
197
+ // slug detection). It decides whether the read-only jobs (verify,
198
+ // close-job) check out with `persist-credentials: false`, which is only
199
+ // provably safe on a PUBLIC repo (every git read works without a token).
200
+ // Detected AFTER the export early-return: it is a fact about the repo,
201
+ // never a config value, so it is not baked into an exported config.
202
+ if (options.ctx.getRepoVisibility) {
203
+ const visibility = await options.ctx
204
+ .getRepoVisibility()
205
+ .catch(() => undefined);
206
+ if (visibility !== undefined) {
207
+ config.repoVisibility = visibility;
208
+ }
209
+ }
210
+ if (config.repoVisibility === 'public') {
211
+ log(
212
+ '🔒 Public repository: verify + close-job check out with persist-credentials: false (no token left in .git/config).',
213
+ );
214
+ } else {
215
+ log(
216
+ `🔒 Repository visibility ${config.repoVisibility ?? 'unknown'}: verify + close-job keep the checkout token ` +
217
+ '(a git fetch in your gate or project-setup hook may need it). Re-run install-ci if the repo becomes public.',
218
+ );
219
+ }
220
+
196
221
  // 3. Orchestrate secrets — SKIPPED in --fake mode (no real secret touched).
197
222
  let secrets: SecretSetResult[] = [];
198
223
  if (fake) {
@@ -68,6 +68,7 @@
68
68
  */
69
69
 
70
70
  import {brand} from './brand.js';
71
+ import {ACTION_PINS, pinnedUses} from './install-ci-action-pins.js';
71
72
  import type {ResolvedCIConfig} from './install-ci-core.js';
72
73
  import {providerSecretsWithBlock} from './install-ci-core.js';
73
74
 
@@ -365,7 +366,7 @@ jobs:
365
366
  if: \${{ github.event.issue.number && !github.event.issue.pull_request }}
366
367
  runs-on: ubuntu-latest
367
368
  steps:
368
- - uses: actions/checkout@v5
369
+ - uses: ${pinnedUses(ACTION_PINS.checkout)}
369
370
  with:
370
371
  fetch-depth: 0
371
372
  - uses: ./.github/actions/dorfl-setup${setupWith}
@@ -463,13 +464,23 @@ jobs:
463
464
  # lone-task review/edit loop and posts its findings as questions back into
464
465
  # THIS issue thread (insertion point E) through the issue-comment seam —
465
466
  # CI surfaces E by invoking intake; it adds no new review mechanism.
467
+ #
468
+ # Every value reaches the shell through \`env:\` as DATA and is read back as a
469
+ # quoted variable, never as \`\${{ }}\` text spliced into the script
470
+ # (GitHub's script-injection guidance). The issue number is an integer and
471
+ # the policy outputs are fixed flags today; routing them through env anyway
472
+ # keeps "no \`\${{ }}\` inside \`run:\`" a rule with no exceptions.
466
473
  env:
467
474
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
475
+ ISSUE_NUMBER: \${{ github.event.issue.number }}
476
+ SPEC_FLAG: \${{ steps.policy.outputs.spec_flag }}
477
+ TASK_FLAG: \${{ steps.policy.outputs.task_flag }}
478
+ ORIGIN_TRUST_FLAG: \${{ steps.policy.outputs.origin_trust_flag }}
468
479
  run: |
469
- dorfl intake "\${{ github.event.issue.number }}" \\
470
- "\${{ steps.policy.outputs.spec_flag }}" \\
471
- "\${{ steps.policy.outputs.task_flag }}" \\
472
- "\${{ steps.policy.outputs.origin_trust_flag }}" \\
480
+ dorfl intake "\${ISSUE_NUMBER}" \\
481
+ "\${SPEC_FLAG}" \\
482
+ "\${TASK_FLAG}" \\
483
+ "\${ORIGIN_TRUST_FLAG}" \\
473
484
  --arbiter origin
474
485
  `;
475
486
  }
@@ -521,10 +532,20 @@ export function validateIntakeWorkflow(text: string): IntakeTriggerValidation {
521
532
  'schedules).');
522
533
  // The issue number rides the explicit positional — never a bare slug, and the
523
534
  // number comes from the event payload (the issue under intake).
524
- require('intake-explicit-issue-number', /dorfl intake "?\$\{\{\s*github\.event\.issue\.number/.test(
535
+ require('intake-explicit-issue-number', /ISSUE_NUMBER:\s*\$\{\{\s*github\.event\.issue\.number\s*\}\}[\s\S]*?dorfl intake "\$\{ISSUE_NUMBER\}"/.test(
525
536
  operative,
526
537
  ), 'the intake invocation must pass the explicit issue NUMBER ' +
527
- '(`github.event.issue.number`), never a bare slug.');
538
+ '(`github.event.issue.number`, via the step env as `ISSUE_NUMBER`), never a bare slug.');
539
+ // No `${{ }}` spliced into the intake `run:` script (script injection): the
540
+ // number and the policy flags reach the shell through the step `env:`.
541
+ require('intake-args-not-spliced-into-run', !/dorfl intake "?\$\{\{/.test(
542
+ operative,
543
+ ) &&
544
+ !/"\$\{\{\s*steps\.policy\.outputs\./.test(
545
+ operative,
546
+ ), 'the intake invocation must NOT interpolate `${{ github.event.issue.number }}` ' +
547
+ 'or `${{ steps.policy.outputs.* }}` into its `run:` script: pass them through ' +
548
+ 'the step `env:` and quote the variables.');
528
549
  // CI owns ONLY the trigger/policy/delivery — it must NOT invoke a build/task
529
550
  // verb (that is the build/task tick), nor re-implement the transform.
530
551
  require('no-build-verbs', !/dorfl (?:do|advance)\b/.test(
package/src/intake.ts CHANGED
@@ -3,6 +3,7 @@ import {dirname, join} from 'node:path';
3
3
  import {runAsync, type RunResult} from './git.js';
4
4
  import {workFolderRel, workFolderPrefix} from './work-layout.js';
5
5
  import {paramCase} from './brand.js';
6
+ import {ensureSafeSlug} from './slug-safety.js';
6
7
  import {
7
8
  performIntegration,
8
9
  type IntegrationCoreResult,
@@ -1714,28 +1715,32 @@ async function postCompletionComment(params: {
1714
1715
  * Resolve a content-derived slug from the verdict — NEVER a counter (spec `issue-intake` US #8).
1715
1716
  * Prefer the drafted `taskSlug`, else derive from the drafted title; both go
1716
1717
  * through `paramCase` (the brand case-transform) so the result is a clean
1717
- * lowercase-`-`-joined slug. An empty result (no slug AND no title) signals the
1718
- * caller to refuse (a counter fallback is forbidden).
1718
+ * lowercase-`-`-joined slug, and then through {@link ensureSafeSlug}: the draft
1719
+ * comes from issue content, and `paramCase` alone keeps characters like `$(`,
1720
+ * `"`, `;` and `/` that must never reach a file name, a git ref or a CI command
1721
+ * line. An empty result (no slug AND no title) signals the caller to refuse (a
1722
+ * counter fallback is forbidden).
1719
1723
  */
1720
1724
  function resolveSlug(verdict: IntakeVerdict): string {
1721
1725
  const candidate =
1722
1726
  verdict.taskSlug && verdict.taskSlug.trim() !== ''
1723
1727
  ? verdict.taskSlug
1724
1728
  : (verdict.taskTitle ?? '');
1725
- return paramCase(candidate);
1729
+ return ensureSafeSlug(paramCase(candidate));
1726
1730
  }
1727
1731
 
1728
1732
  /**
1729
1733
  * Resolve a content-derived slug for the spec outcome — NEVER a counter (spec `issue-intake` US #8).
1730
1734
  * Prefer the drafted `specSlug`, else derive from the drafted spec title; both go
1731
- * through `paramCase`. An empty result signals the caller to refuse.
1735
+ * through `paramCase` and {@link ensureSafeSlug} (see {@link resolveSlug}). An
1736
+ * empty result signals the caller to refuse.
1732
1737
  */
1733
1738
  function resolveSpecSlug(verdict: IntakeVerdict): string {
1734
1739
  const candidate =
1735
1740
  verdict.specSlug && verdict.specSlug.trim() !== ''
1736
1741
  ? verdict.specSlug
1737
1742
  : (verdict.specTitle ?? '');
1738
- return paramCase(candidate);
1743
+ return ensureSafeSlug(paramCase(candidate));
1739
1744
  }
1740
1745
 
1741
1746
  /**
package/src/mint-adr.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  type CreateItemThroughCasResult,
8
8
  } from './advancing-lock.js';
9
9
  import {resolveSidecarIdentity, sidecarPathFor} from './sidecar.js';
10
+ import {ensureSafeSlug} from './slug-safety.js';
10
11
 
11
12
  /**
12
13
  * The **ADR-MINT route** (spec
@@ -185,7 +186,9 @@ export async function mintAdr(options: MintAdrOptions): Promise<MintAdrResult> {
185
186
  const note = options.note ?? (() => {});
186
187
 
187
188
  const {slug: obsSlug} = resolveSidecarIdentity(item);
188
- const adrSlug = (options.adrSlug ?? obsSlug).trim();
189
+ // The drafted slug comes from an agent verdict: an unsafe one (a `/`, `..`,
190
+ // shell metacharacters) is sanitised, a safe one is kept exactly as drafted.
191
+ const adrSlug = ensureSafeSlug(options.adrSlug ?? obsSlug);
189
192
  if (adrSlug === '') {
190
193
  return {
191
194
  outcome: 'usage-error',
@@ -1,4 +1,5 @@
1
1
  import {ledgerRead, type LedgerReadStrategy} from './ledger-read.js';
2
+ import {isSafeSlug, MAX_SLUG_LENGTH} from './slug-safety.js';
2
3
 
3
4
  /**
4
5
  * The **§3a slug-namespace resolver** (`docs/adr/command-surface-and-
@@ -44,8 +45,8 @@ import {ledgerRead, type LedgerReadStrategy} from './ledger-read.js';
44
45
  *
45
46
  * HARD CUTOVER (spec `prd-to-spec-vocabulary-cutover-and-migration-command`,
46
47
  * contract step): the legacy ''prd'' member is GONE — the parent-spec namespace
47
- * is `'spec'` only. A ''prd:<slug>'' arg no longer parses to a namespace (it falls
48
- * through to a bare literal slug), and a `work/prd-<slug>` branch ref no longer
48
+ * is `'spec'` only. A ''prd:<slug>'' arg no longer parses to a namespace (the
49
+ * resolvers refuse it by the safe-slug check: `:` is not a slug character), and a `work/prd-<slug>` branch ref no longer
49
50
  * parses. No back-compat alias (the clean-break stance).
50
51
  */
51
52
  export type SlugNamespace = 'task' | 'spec' | 'observation';
@@ -155,8 +156,8 @@ const TASK_PREFIX = 'task:';
155
156
  /**
156
157
  * The parent-spec namespace prefix: `spec:<slug>` → `{explicit: 'spec'}`. HARD
157
158
  * CUTOVER (spec `prd-to-spec-vocabulary-cutover-and-migration-command`): the
158
- * legacy ''prd:'' prefix is GONE — a ''prd:<slug>'' arg falls through to a bare
159
- * literal slug (no namespace), the clean-break stance.
159
+ * legacy ''prd:'' prefix is GONE: a ''prd:<slug>'' arg carries no namespace and
160
+ * is refused by the safe-slug check (`:` is not a slug character), the clean-break stance.
160
161
  */
161
162
  const SPEC_PREFIX = 'spec:';
162
163
  /**
@@ -185,7 +186,8 @@ export class SlugResolutionError extends Error {
185
186
  * `spec:foo` → explicit spec, `foo` → bare (`explicit: undefined`). The prefix
186
187
  * match is case-sensitive and exact (`task:`/`spec:`); a slug like `tasked` is
187
188
  * NOT a prefix and stays bare. HARD CUTOVER: the legacy ''prd:'' prefix is not a
188
- * namespace prefix anymore — ''prd:foo'' stays a bare literal slug.
189
+ * namespace prefix anymore: ''prd:foo'' parses as a bare slug, which the
190
+ * resolvers then refuse by the safe-slug check (`:` is not a slug character).
189
191
  */
190
192
  export function parseSlugArg(arg: string): ParsedSlugArg {
191
193
  if (arg.startsWith(TASK_PREFIX)) {
@@ -206,6 +208,29 @@ export function parseSlugArg(arg: string): ParsedSlugArg {
206
208
  return {explicit: undefined, slug: arg};
207
209
  }
208
210
 
211
+ /**
212
+ * Refuse a slug outside the safe set ({@link isSafeSlug}) before any verb acts on
213
+ * it. Producers sanitise the slugs they mint, but a slug can also arrive from a
214
+ * hand-written frontmatter `slug:` or a file name, and every resolved slug goes
215
+ * on to name files, git refs and CI command lines. Loud, like every other
216
+ * resolution error: an unsafe item is never acted on, and the message says how
217
+ * to fix it.
218
+ */
219
+ function assertSafeSlug(arg: string, slug: string): void {
220
+ if (!isSafeSlug(slug)) {
221
+ throw new SlugResolutionError(
222
+ `'${arg}' does not name a usable slug: a slug must be 1-${MAX_SLUG_LENGTH} ` +
223
+ `letters, digits, '.', '_' or '-', start and end with a letter or digit, ` +
224
+ `and not contain '..'. Rename the item's file (and its frontmatter ` +
225
+ `\`slug:\`, if set) to a safe slug.` +
226
+ (slug.includes(':')
227
+ ? ` (The only namespace prefixes are \`task:\`, \`spec:\`, \`obs:\` ` +
228
+ `and \`observation:\`; a legacy prefix such as \`prd:\` is not one.)`
229
+ : ''),
230
+ );
231
+ }
232
+ }
233
+
209
234
  /** What the resolver needs to perform the existence checks. */
210
235
  export interface ResolveSlugInput {
211
236
  /** The raw CLI slug argument (bare, `task:<slug>`, or `spec:<slug>`). */
@@ -261,6 +286,7 @@ function specExists(
261
286
  export function resolveSlug(input: ResolveSlugInput): ResolvedSlug {
262
287
  const read = input.read ?? ledgerRead;
263
288
  const parsed = parseSlugArg(input.arg);
289
+ assertSafeSlug(input.arg, parsed.slug);
264
290
 
265
291
  if (parsed.explicit === 'observation') {
266
292
  // `do` spans the task/spec namespaces ONLY (build a task OR task a spec).
@@ -313,6 +339,7 @@ export function resolveSlug(input: ResolveSlugInput): ResolvedSlug {
313
339
  export function resolveAdvanceArg(input: ResolveSlugInput): ResolvedSlug {
314
340
  const read = input.read ?? ledgerRead;
315
341
  const parsed = parseSlugArg(input.arg);
342
+ assertSafeSlug(input.arg, parsed.slug);
316
343
 
317
344
  if (parsed.explicit !== undefined) {
318
345
  // Explicit prefix (task / spec / observation): unambiguous by construction.
@@ -348,6 +375,7 @@ export function resolveAdvanceArg(input: ResolveSlugInput): ResolvedSlug {
348
375
  */
349
376
  export function resolveTaskOnlyArg(arg: string): string {
350
377
  const parsed = parseSlugArg(arg);
378
+ assertSafeSlug(arg, parsed.slug);
351
379
  if (parsed.explicit === 'spec') {
352
380
  throw new SlugResolutionError(
353
381
  `this command operates on tasks, not specs — '${arg}' names a spec. ` +