dorfl 0.14.1 → 0.14.2

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 (61) 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 +73 -21
  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 +15 -2
  11. package/dist/close-job-template.js.map +1 -1
  12. package/dist/install-ci-core.d.ts +77 -0
  13. package/dist/install-ci-core.d.ts.map +1 -1
  14. package/dist/install-ci-core.js +94 -4
  15. package/dist/install-ci-core.js.map +1 -1
  16. package/dist/install-ci-github.d.ts +15 -1
  17. package/dist/install-ci-github.d.ts.map +1 -1
  18. package/dist/install-ci-github.js +32 -0
  19. package/dist/install-ci-github.js.map +1 -1
  20. package/dist/install-ci.d.ts.map +1 -1
  21. package/dist/install-ci.js +21 -0
  22. package/dist/install-ci.js.map +1 -1
  23. package/dist/intake-trigger-template.d.ts.map +1 -1
  24. package/dist/intake-trigger-template.js +22 -6
  25. package/dist/intake-trigger-template.js.map +1 -1
  26. package/dist/intake.d.ts.map +1 -1
  27. package/dist/intake.js +10 -5
  28. package/dist/intake.js.map +1 -1
  29. package/dist/mint-adr.d.ts.map +1 -1
  30. package/dist/mint-adr.js +4 -1
  31. package/dist/mint-adr.js.map +1 -1
  32. package/dist/slug-namespace.d.ts +4 -3
  33. package/dist/slug-namespace.d.ts.map +1 -1
  34. package/dist/slug-namespace.js +28 -3
  35. package/dist/slug-namespace.js.map +1 -1
  36. package/dist/slug-safety.d.ts +55 -0
  37. package/dist/slug-safety.d.ts.map +1 -0
  38. package/dist/slug-safety.js +76 -0
  39. package/dist/slug-safety.js.map +1 -0
  40. package/dist/triage-persist.d.ts.map +1 -1
  41. package/dist/triage-persist.js +12 -3
  42. package/dist/triage-persist.js.map +1 -1
  43. package/dist/verify-workflow-template.d.ts +1 -1
  44. package/dist/verify-workflow-template.d.ts.map +1 -1
  45. package/dist/verify-workflow-template.js +14 -2
  46. package/dist/verify-workflow-template.js.map +1 -1
  47. package/package.json +3 -2
  48. package/src/advance-ci-template.ts +12 -4
  49. package/src/advance-lifecycle-template.ts +75 -21
  50. package/src/cli.ts +2 -2
  51. package/src/close-job-template.ts +15 -2
  52. package/src/install-ci-core.ts +130 -4
  53. package/src/install-ci-github.ts +40 -1
  54. package/src/install-ci.ts +25 -0
  55. package/src/intake-trigger-template.ts +26 -6
  56. package/src/intake.ts +10 -5
  57. package/src/mint-adr.ts +4 -1
  58. package/src/slug-namespace.ts +33 -5
  59. package/src/slug-safety.ts +83 -0
  60. package/src/triage-persist.ts +13 -7
  61. package/src/verify-workflow-template.ts +14 -2
@@ -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) {
@@ -463,13 +463,23 @@ jobs:
463
463
  # lone-task review/edit loop and posts its findings as questions back into
464
464
  # THIS issue thread (insertion point E) through the issue-comment seam —
465
465
  # CI surfaces E by invoking intake; it adds no new review mechanism.
466
+ #
467
+ # Every value reaches the shell through \`env:\` as DATA and is read back as a
468
+ # quoted variable, never as \`\${{ }}\` text spliced into the script
469
+ # (GitHub's script-injection guidance). The issue number is an integer and
470
+ # the policy outputs are fixed flags today; routing them through env anyway
471
+ # keeps "no \`\${{ }}\` inside \`run:\`" a rule with no exceptions.
466
472
  env:
467
473
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
474
+ ISSUE_NUMBER: \${{ github.event.issue.number }}
475
+ SPEC_FLAG: \${{ steps.policy.outputs.spec_flag }}
476
+ TASK_FLAG: \${{ steps.policy.outputs.task_flag }}
477
+ ORIGIN_TRUST_FLAG: \${{ steps.policy.outputs.origin_trust_flag }}
468
478
  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 }}" \\
479
+ dorfl intake "\${ISSUE_NUMBER}" \\
480
+ "\${SPEC_FLAG}" \\
481
+ "\${TASK_FLAG}" \\
482
+ "\${ORIGIN_TRUST_FLAG}" \\
473
483
  --arbiter origin
474
484
  `;
475
485
  }
@@ -521,10 +531,20 @@ export function validateIntakeWorkflow(text: string): IntakeTriggerValidation {
521
531
  'schedules).');
522
532
  // The issue number rides the explicit positional — never a bare slug, and the
523
533
  // number comes from the event payload (the issue under intake).
524
- require('intake-explicit-issue-number', /dorfl intake "?\$\{\{\s*github\.event\.issue\.number/.test(
534
+ require('intake-explicit-issue-number', /ISSUE_NUMBER:\s*\$\{\{\s*github\.event\.issue\.number\s*\}\}[\s\S]*?dorfl intake "\$\{ISSUE_NUMBER\}"/.test(
525
535
  operative,
526
536
  ), 'the intake invocation must pass the explicit issue NUMBER ' +
527
- '(`github.event.issue.number`), never a bare slug.');
537
+ '(`github.event.issue.number`, via the step env as `ISSUE_NUMBER`), never a bare slug.');
538
+ // No `${{ }}` spliced into the intake `run:` script (script injection): the
539
+ // number and the policy flags reach the shell through the step `env:`.
540
+ require('intake-args-not-spliced-into-run', !/dorfl intake "?\$\{\{/.test(
541
+ operative,
542
+ ) &&
543
+ !/"\$\{\{\s*steps\.policy\.outputs\./.test(
544
+ operative,
545
+ ), 'the intake invocation must NOT interpolate `${{ github.event.issue.number }}` ' +
546
+ 'or `${{ steps.policy.outputs.* }}` into its `run:` script: pass them through ' +
547
+ 'the step `env:` and quote the variables.');
528
548
  // CI owns ONLY the trigger/policy/delivery — it must NOT invoke a build/task
529
549
  // verb (that is the build/task tick), nor re-implement the transform.
530
550
  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. ` +
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The SAFE-SLUG character set, and the one sanitiser every slug-producing path
3
+ * shares.
4
+ *
5
+ * A slug is not just a label: it becomes a file name (`work/<folder>/<slug>.md`),
6
+ * part of a git ref (`work/<slug>`, `work/intake-task-<slug>`), a command-line
7
+ * argument (`dorfl advance "task:<slug>"`, one per CI matrix leg) and text in
8
+ * commit messages. Several producers draft slugs from content an outsider can
9
+ * influence: `intake` from an issue's title and thread, the triage and apply
10
+ * rungs from an agent verdict. Before this module the only transform was
11
+ * `paramCase`, which keeps `$(`, backticks, quotes, `;`, `|` and `/`, and the
12
+ * triage/ADR paths only `trim()`med, so a drafted `../x` escaped its folder.
13
+ *
14
+ * Two functions, two jobs:
15
+ *
16
+ * - {@link toSafeSlug} is for PRODUCERS: it turns any drafted text into a slug
17
+ * made only of ASCII letters, digits and single hyphens. Its output always
18
+ * passes {@link isSafeSlug}.
19
+ * - {@link isSafeSlug} is for CONSUMERS: the slug resolvers refuse any slug
20
+ * outside the safe set, which covers slugs no producer here minted (a
21
+ * frontmatter `slug:` a human or agent wrote by hand). It is deliberately a
22
+ * little wider than what {@link toSafeSlug} emits (it also allows `.` and
23
+ * `_` and mixed case) so every slug already in use in a real `work/` tree
24
+ * keeps resolving: those characters are inert in a file name, a ref and a
25
+ * quoted shell word. `..` is still refused (path traversal), as is a leading
26
+ * `-` (it would read as a command-line option) or `.` (a hidden file), and so
27
+ * is a `.lock` suffix (git refuses it in a ref component).
28
+ */
29
+
30
+ /**
31
+ * The longest slug either function accepts. Real slugs in this repo top out
32
+ * around 105 characters; 120 leaves room without letting a drafted essay become
33
+ * a file name or ref component.
34
+ */
35
+ export const MAX_SLUG_LENGTH = 120;
36
+
37
+ /** Letters, digits, `.`, `_`, `-`; starts and ends with a letter or digit. */
38
+ const SAFE_SLUG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
39
+
40
+ /** Git refuses a ref component ending in `.lock` (its lock-file suffix). */
41
+ const GIT_LOCK_SUFFIX_RE = /\.lock$/i;
42
+
43
+ /**
44
+ * Whether `slug` is safe to use as a file name, a git ref component and a
45
+ * command-line argument (see the module doc for the exact set).
46
+ */
47
+ export function isSafeSlug(slug: string): boolean {
48
+ return (
49
+ slug.length > 0 &&
50
+ slug.length <= MAX_SLUG_LENGTH &&
51
+ SAFE_SLUG_RE.test(slug) &&
52
+ !slug.includes('..') &&
53
+ !GIT_LOCK_SUFFIX_RE.test(slug)
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Turn drafted text into a safe slug: accents are folded to their base letter,
59
+ * every run of anything other than an ASCII letter or digit becomes ONE hyphen,
60
+ * leading/trailing hyphens are dropped, and the result is capped at
61
+ * {@link MAX_SLUG_LENGTH}. Case is preserved (callers that want lower case, like
62
+ * `intake`, `paramCase` first). Returns `''` when nothing usable is left, so the
63
+ * caller's existing "no slug to derive" refusal fires instead of a junk name.
64
+ */
65
+ export function toSafeSlug(input: string): string {
66
+ return input
67
+ .normalize('NFKD')
68
+ .replace(/[\u0300-\u036f]/g, '')
69
+ .replace(/[^A-Za-z0-9]+/g, '-')
70
+ .replace(/^-+|-+$/g, '')
71
+ .slice(0, MAX_SLUG_LENGTH)
72
+ .replace(/-+$/, '');
73
+ }
74
+
75
+ /**
76
+ * Keep a slug that is already safe EXACTLY as it is, and sanitise one that is
77
+ * not. The producers use this so every input that worked before still yields
78
+ * the same slug, and only an unsafe one changes.
79
+ */
80
+ export function ensureSafeSlug(input: string): string {
81
+ const trimmed = input.trim();
82
+ return isSafeSlug(trimmed) ? trimmed : toSafeSlug(trimmed);
83
+ }
@@ -11,6 +11,7 @@ import {resolveSidecarIdentity, sidecarPathFor} from './sidecar.js';
11
11
  import type {TriageAutoKind} from './triage-gate.js';
12
12
  import {renderTaskBody, renderSpecBody} from './buildable-body.js';
13
13
  import {setFrontmatterMarker} from './frontmatter.js';
14
+ import {ensureSafeSlug} from './slug-safety.js';
14
15
 
15
16
  /**
16
17
  * The engine-owned TRIAGE PERSIST (spec `advance-loop`, task `advance-rung-triage`,
@@ -433,7 +434,9 @@ export async function promoteObservation(
433
434
  const note = options.note ?? (() => {});
434
435
 
435
436
  const {slug: obsSlug} = resolveSidecarIdentity(item);
436
- const newSlug = (options.newSlug ?? obsSlug).trim();
437
+ // The drafted slug comes from an agent verdict: an unsafe one (a `/`, `..`,
438
+ // shell metacharacters) is sanitised, a safe one is kept exactly as drafted.
439
+ const newSlug = ensureSafeSlug(options.newSlug ?? obsSlug);
437
440
  const artifact = options.artifact ?? 'task';
438
441
  if (newSlug === '') {
439
442
  return {
@@ -460,13 +463,16 @@ export async function promoteObservation(
460
463
  // review-nits back-pointer); this is the triage-promote lineage. Matching on this
461
464
  // explicit field — not the slug — is what keeps an unrelated same-slug task from
462
465
  // false-positiving into a benign skip.
466
+ // A drafted `stubContent` carries its own frontmatter, and the ledger reads
467
+ // `slug:` before the file name, so stamp the SAFE slug over whatever the
468
+ // draft said: otherwise a drafted `slug: ../x` would become the item's
469
+ // identity even though the file name was sanitised.
470
+ const drafted =
471
+ options.stubContent !== undefined
472
+ ? setFrontmatterMarker(options.stubContent, 'slug', newSlug)
473
+ : buildPromotedBody(artifact, newSlug, readItem(cwd, itemPath, env));
463
474
  const content = setFrontmatterMarker(
464
- ensureTaskDispatchable(
465
- artifact,
466
- newSlug,
467
- options.stubContent ??
468
- buildPromotedBody(artifact, newSlug, readItem(cwd, itemPath, env)),
469
- ),
475
+ ensureTaskDispatchable(artifact, newSlug, drafted),
470
476
  'promotedFrom',
471
477
  item,
472
478
  );
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type {ResolvedCIConfig} from './install-ci-core.js';
25
+ import {shouldDropCheckoutCredentials} from './install-ci-core.js';
25
26
 
26
27
  /**
27
28
  * The required-status-check CONTEXT install-ci sets via branch protection AND the
@@ -47,7 +48,18 @@ export const VERIFY_WORKFLOW_PATH = 'workflows/verify.yml';
47
48
  * so the GitHub check it produces matches the required-status `context` install-ci
48
49
  * configures on `main` — by construction, not by hand-typed agreement.
49
50
  */
50
- export function generateVerifyWorkflow(_config: ResolvedCIConfig): string {
51
+ export function generateVerifyWorkflow(config: ResolvedCIConfig): string {
52
+ // `persist-credentials: false` ONLY on a repo KNOWN to be public (see
53
+ // {@link shouldDropCheckoutCredentials}): this job has `contents: read`, so
54
+ // the persisted token can only ever read, and on a public repo reads need
55
+ // no token. Unknown / private / internal ⇒ today's checkout, unchanged.
56
+ const checkoutCredentials = shouldDropCheckoutCredentials(config)
57
+ ? `
58
+ # Public repository: every git read works without a token, and this
59
+ # job cannot push, so do not leave the token in .git/config for later
60
+ # steps (the project-setup hook's installs, the gate) to read.
61
+ persist-credentials: false`
62
+ : '';
51
63
  return `\
52
64
  # dorfl — the VERIFY workflow (Tier-1 GitHub ceiling; spec
53
65
  # land-time-reverify-and-parallel-merge-ceiling, task
@@ -102,7 +114,7 @@ jobs:
102
114
  steps:
103
115
  - uses: actions/checkout@v5
104
116
  with:
105
- fetch-depth: 0
117
+ fetch-depth: 0${checkoutCredentials}
106
118
  - uses: ./.github/actions/dorfl-setup
107
119
  - name: run the repo verify gate
108
120
  run: dorfl verify