@hone-ai/cli 1.40.0 → 1.42.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.
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ /**
3
+ * HC-GF-396: the one place the CLI decides which Hone API host it talks to.
4
+ *
5
+ * The old compiled default, https://api.hone.ai, sits on a domain the owner does not hold (it is listed
6
+ * for sale), so whoever buys it could receive every adopter's Bearer token and admin key. The default
7
+ * is now the Railway host production already serves, and every source of an API URL — `--api`,
8
+ * `HONE_API`, `~/.honerc` `api` — goes through `resolveApiUrl()`, which refuses a retired host however
9
+ * it is spelled (case, port, path, trailing dot, subdomain) and refuses anything that is not an
10
+ * http(s) URL.
11
+ *
12
+ * Design: docs/architecture/HC-GF-396-default-api-host.md. Copies of DEFAULT_API that cannot require
13
+ * this file (the server, files installed into adopter repos) are held equal to it by
14
+ * tests/regression/HC-GF-396-default-api-agreement.test.js.
15
+ */
16
+
17
+ const DEFAULT_API = 'https://hone-server-production.up.railway.app';
18
+ const RETIRED_HOSTS = ['hone.ai'];
19
+
20
+ /**
21
+ * Classify one candidate value.
22
+ * Returns { ok: true, value, hostname } for a usable http(s) URL on a host that is not retired,
23
+ * { ok: false, reason: 'retired', hostname } for a retired host, and { ok: false, reason: 'invalid' }
24
+ * for anything that does not parse or is not http(s). `hostname` is lower-cased with one trailing
25
+ * dot removed; it is the only part of the value any message may print.
26
+ */
27
+ function classifyApiUrl(value) {
28
+ if (typeof value !== 'string') return { ok: false, reason: 'invalid' };
29
+ let u;
30
+ try { u = new URL(value.trim()); } catch { return { ok: false, reason: 'invalid' }; }
31
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return { ok: false, reason: 'invalid' };
32
+ let hostname = u.hostname.toLowerCase();
33
+ if (hostname.endsWith('.')) hostname = hostname.slice(0, -1);
34
+ if (!hostname) return { ok: false, reason: 'invalid' };
35
+ const retired = RETIRED_HOSTS.some((h) => hostname === h || hostname.endsWith(`.${h}`));
36
+ if (retired) return { ok: false, reason: 'retired', hostname };
37
+ return { ok: true, value: value.trim(), hostname };
38
+ }
39
+
40
+ /** The hostname of a URL for display, or a fixed placeholder when it does not parse. */
41
+ function apiHostname(value) {
42
+ try { return new URL(value).hostname; } catch { return '(unparseable URL)'; }
43
+ }
44
+
45
+ const SOURCE_LABEL = { env: 'HONE_API', rc: '~/.honerc "api"' };
46
+
47
+ function describeRefusal(label, verdict) {
48
+ return verdict.reason === 'retired'
49
+ ? `${label} names ${verdict.hostname}, a retired host that is no longer a Hone server`
50
+ : `${label} is not a valid http(s) URL`;
51
+ }
52
+
53
+ // One warning per process: stdout carries the MCP stdio transport, and a command that resolves the
54
+ // URL twice should not print the same warning twice.
55
+ let _warned = false;
56
+ function _resetWarningForTests() { _warned = false; }
57
+
58
+ const present = (v) => v !== undefined && v !== null && v !== '';
59
+
60
+ /**
61
+ * Resolve the API base URL. Order: `flag` (only `hone init --api`) → `env` (HONE_API) → `rc`
62
+ * (~/.honerc api) → DEFAULT_API.
63
+ *
64
+ * A retired or invalid `flag` is an error: the result carries `error` and no `apiUrl`, and the caller
65
+ * exits 2 without sending anything. A retired or invalid `env` or `rc` counts as UNSET, so resolution
66
+ * falls through to the next source, and one warning naming the hostname only goes to stderr.
67
+ *
68
+ * @returns {{ apiUrl: string|null, source: 'flag'|'env'|'rc'|'default', error?: string }}
69
+ */
70
+ function resolveApiUrl({ flag, env, rc } = {}, { warn = (m) => process.stderr.write(`${m}\n`) } = {}) {
71
+ if (present(flag)) {
72
+ const v = classifyApiUrl(flag);
73
+ if (v.ok) return { apiUrl: v.value, source: 'flag' };
74
+ return {
75
+ apiUrl: null,
76
+ source: 'flag',
77
+ error: `${describeRefusal('--api', v)}. Omit --api to use the default, ${DEFAULT_API}.`,
78
+ };
79
+ }
80
+ const refused = [];
81
+ for (const [source, value] of [['env', env], ['rc', rc]]) {
82
+ if (!present(value)) continue;
83
+ const v = classifyApiUrl(value);
84
+ if (v.ok) {
85
+ if (refused.length) _warnOnce(refused, source, v.hostname, warn);
86
+ return { apiUrl: v.value, source };
87
+ }
88
+ refused.push(describeRefusal(SOURCE_LABEL[source], v));
89
+ }
90
+ if (refused.length) _warnOnce(refused, 'default', apiHostname(DEFAULT_API), warn);
91
+ return { apiUrl: DEFAULT_API, source: 'default' };
92
+ }
93
+
94
+ function _warnOnce(refused, source, hostname, warn) {
95
+ if (_warned) return;
96
+ _warned = true;
97
+ const using = source === 'default' ? `the default host ${hostname}` : `${SOURCE_LABEL[source]} (${hostname})`;
98
+ warn(`Warning: ${refused.join('; ')}, so it is ignored. Using ${using}. ` +
99
+ 'Fix it with hone init --api <url>, or unset HONE_API.');
100
+ }
101
+
102
+ module.exports = {
103
+ DEFAULT_API,
104
+ RETIRED_HOSTS,
105
+ classifyApiUrl,
106
+ resolveApiUrl,
107
+ apiHostname,
108
+ _resetWarningForTests,
109
+ };
@@ -27,14 +27,20 @@ function isEditorParked(status) {
27
27
  /**
28
28
  * The parked-state copy (ux-copy pass, 2026-09-05): what happened, where it is,
29
29
  * the two ways forward (editor MCP tools / CLI verbs), and the closing step.
30
+ *
31
+ * The submit command depends on the contract step-instruction is served, which this copy is printed
32
+ * before: `--file` is refused under contracts 2 and 3. So it shows each form beside the contract it
33
+ * belongs to and sends the operator to the one step-instruction prints, never to a lone `--file`.
30
34
  */
31
35
  function editorParkedGuidance(runId, stepKey, agent) {
32
36
  const a = agent ? ` (${agent})` : '';
33
37
  return [
34
38
  `⏸ Parked for your editor at ${stepKey}${a} — this step runs in your editor AI, not on Hone.`,
35
- ` In Claude Code: call hone_step_instruction, build the step there, then hone_submit_step.`,
39
+ ` In Claude Code: call hone_step_instruction, build the step there, then hone_submit_step as it says.`,
36
40
  ` From the CLI: hone step-instruction ${runId}`,
37
- ` … build … then: hone submit-step ${runId} ${stepKey} --file out.md`,
41
+ ` … build … then submit with the command it prints for its contract:`,
42
+ ` contract 2 or 3: hone submit-step ${runId} ${stepKey} --from-tree --contract <2|3>`,
43
+ ` contract 1: hone submit-step ${runId} ${stepKey} --file out.md`,
38
44
  ` When submitted: hone run-story ${runId} --approve ${stepKey}`,
39
45
  ` Guide: docs/runbooks/editor-mode.md`,
40
46
  ].join('\n');
@@ -35,7 +35,7 @@ const path = require('node:path');
35
35
  * diff — so the count stays git's, exactly as for whole-file blocks. See
36
36
  * .github/pipeline/HC-019n-followup-27/architect.md.
37
37
  */
38
- const { FILE_BLOCK_RE, EDIT_BLOCK_RE, EDIT_OPEN_RE, EDIT_SPLIT_RE, DELETE_RE } = require('./block-grammar');
38
+ const { FILE_BLOCK_RE, EDIT_BLOCK_RE, EDIT_OPEN_RE, EDIT_SPLIT_RE, DELETE_RE, fileBlockSpans, matchesOutside } = require('./block-grammar');
39
39
 
40
40
  /**
41
41
  * The line-anchored closer necessarily absorbs the newline that terminates the
@@ -106,11 +106,13 @@ function parseBlocksIn(scope) {
106
106
  // HC-GF-109: EVERY edit block for a path, in order. This loop used to keep the first and drop the rest ("first edit
107
107
  // wins") while the code-builder prompt asks for `edit:` on a file changed in a few places, so 12 of 24 stored edit
108
108
  // blocks never reached the patch, and verify-patch type-checked a settings.py missing four of its five edits.
109
+ // HC-GF-404: an edit opener or DELETE: line inside a whole file's content is that file's data.
110
+ const inFile = fileBlockSpans(scope);
109
111
  const edits = [];
110
112
  const malformed = [];
111
113
  const editKeys = new Set();
112
114
  const parsedAt = new Set();
113
- for (const m of scope.matchAll(EDIT_BLOCK_RE)) {
115
+ for (const m of matchesOutside(EDIT_BLOCK_RE, scope, inFile)) {
114
116
  parsedAt.add(m.index);
115
117
  const rel = normalizeRepoPath(m[2]); // HC-GF-290: m[1] is the fence now
116
118
  if (!rel) continue;
@@ -126,14 +128,14 @@ function parseBlocksIn(scope) {
126
128
  editKeys.add(key);
127
129
  edits.push(edit);
128
130
  }
129
- for (const m of scope.matchAll(EDIT_OPEN_RE)) {
131
+ for (const m of matchesOutside(EDIT_OPEN_RE, scope, inFile)) {
130
132
  if (parsedAt.has(m.index)) continue;
131
133
  const rel = normalizeRepoPath(m[1]);
132
134
  if (rel && !seen.has(rel)) malformed.push({ path: rel, reason: 'no closing fence' });
133
135
  }
134
136
 
135
137
  const deletes = [];
136
- for (const m of scope.matchAll(DELETE_RE)) {
138
+ for (const m of matchesOutside(DELETE_RE, scope, inFile)) {
137
139
  const rel = normalizeRepoPath(m[1]);
138
140
  if (rel && !deletes.includes(rel)) deletes.push(rel);
139
141
  }
@@ -272,19 +274,20 @@ function countOccurrences(hay, needle) {
272
274
  /**
273
275
  * Assemble one unified diff from parsed blocks — the SHARED materialization used
274
276
  * by check-patch, verify-patch, and emit-pr (HC-019n-followup-27 de-triplicated
275
- * what was three copies of this loop). Whole-file blocks, region edits, and
276
- * deletes all resolve to git-authored hunks.
277
+ * what was three copies of this loop). Whole-file blocks and region edits resolve
278
+ * to git-authored hunks; a delete is a hand-built removal hunk, headed as `git diff` heads one.
277
279
  *
278
280
  * Injected primitives keep it testable without a real repo:
279
281
  * - runGitDiff(origArg, candidatePath) → { stdout, code } (git diff --no-index)
280
282
  * - readFile(repoRelPath) → string|null (real full file, or null)
281
283
  * - exists(repoRelPath) → boolean
282
284
  * - tmpFile(contents) → { path, cleanup } (write a temp candidate)
285
+ * - fileMode(repoRelPath) → '100644'|'100755'|null (optional; a deletion's mode line, 100644 when absent)
283
286
  *
284
287
  * @returns {{ diff: string, partCount: number }}
285
288
  * @throws {MaterializeError} from the region-edit apply path
286
289
  */
287
- function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile }) {
290
+ function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile, fileMode }) {
288
291
  const parts = [];
289
292
 
290
293
  // HC-GF-109: an edit block that could not be read is work that would otherwise vanish from the patch. Refuse the whole
@@ -335,15 +338,22 @@ function buildDiff(blocks, { runGitDiff, readFile, exists, tmpFile }) {
335
338
  } finally { t.cleanup(); }
336
339
  }
337
340
 
338
- // 3. Deletions — a whole-file removal hunk against the real file on disk.
341
+ // 3. Deletions — a whole-file removal hunk against the real file on disk, headed as `git diff` heads one.
342
+ // Without the `deleted file mode` line git apply reads `+++ /dev/null` as a new file at `dev/null`: it deletes
343
+ // the file AND leaves `dev/null` in the tree, which emit-pr's `git add -A` then commits.
339
344
  for (const del of blocks.deletes || []) {
340
345
  if (!exists(del)) continue;
341
346
  const body = readFile(del);
342
347
  if (body == null) continue;
343
- const lines = body.replace(/\n$/, '').split('\n');
344
- parts.push(
345
- `diff --git a/${del} b/${del}\n--- a/${del}\n+++ /dev/null\n` +
346
- `@@ -1,${lines.length} +0,0 @@\n` + lines.map(l => `-${l}`).join('\n') + '\n');
348
+ const mode = (fileMode && fileMode(del)) || '100644';
349
+ let part = `diff --git a/${del} b/${del}\ndeleted file mode ${mode}\n`;
350
+ if (body !== '') {
351
+ const lines = body.replace(/\n$/, '').split('\n');
352
+ part += `--- a/${del}\n+++ /dev/null\n` +
353
+ `@@ -1,${lines.length} +0,0 @@\n` + lines.map(l => `-${l}`).join('\n') + '\n' +
354
+ (body.endsWith('\n') ? '' : '\\n');
355
+ }
356
+ parts.push(part);
347
357
  }
348
358
 
349
359
  return { diff: parts.join(''), partCount: parts.length };
@@ -434,7 +444,7 @@ function ensureTrailingNewline(s) {
434
444
  * stays side-effect-free to import for the pure-function unit tests.
435
445
  *
436
446
  * @param {{cwd?: string}} [o]
437
- * @returns {{ exists: Function, readFile: Function, tmpFile: Function, runGitDiff: Function }}
447
+ * @returns {{ exists: Function, readFile: Function, fileMode: Function, tmpFile: Function, runGitDiff: Function }}
438
448
  */
439
449
  function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
440
450
  const fs = require('node:fs');
@@ -448,6 +458,9 @@ function nodeMaterializeIO({ cwd = process.cwd() } = {}) {
448
458
  const abs = p.resolve(cwd, rel);
449
459
  return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
450
460
  },
461
+ fileMode: (rel) => {
462
+ try { return (fs.statSync(p.resolve(cwd, rel)).mode & 0o111) ? '100755' : '100644'; } catch { return null; }
463
+ },
451
464
  tmpFile: (contents) => {
452
465
  // HC-GF-304: its own directory, not a name in the shared one that another local user can predict and plant.
453
466
  const t = require('./private-temp').privateTempFile('hone-mat', `candidate-${n++}`);
@@ -493,6 +506,12 @@ function headMaterializeIO({ cwd = process.cwd(), rev = 'HEAD' } = {}) {
493
506
  ...base,
494
507
  exists: (rel) => { try { execFileSync('git', ['cat-file', '-e', `${rev}:${rel}`], opts); return true; } catch { return false; } },
495
508
  readFile: show,
509
+ fileMode: (rel) => {
510
+ try {
511
+ const m = execFileSync('git', ['ls-tree', '--full-tree', rev, '--', rel], { ...opts, encoding: 'utf8' }).split(' ')[0];
512
+ return /^100(644|755)$/.test(m) ? m : null;
513
+ } catch { return null; }
514
+ },
496
515
  runGitDiff: (origArg, candPath) => {
497
516
  if (origArg === '/dev/null') return base.runGitDiff(origArg, candPath);
498
517
  const body = show(origArg);
package/lib/mcp-tools.js CHANGED
@@ -14,6 +14,7 @@ const fsSync = require('node:fs');
14
14
  const os = require('node:os');
15
15
  const path = require('node:path');
16
16
  const { privateTempFile } = require('./private-temp');
17
+ const { resolveApiUrl } = require('./default-api'); // HC-GF-396
17
18
 
18
19
  // Resolve the CLI binary. Default `hone` (the installed bin); overridable for tests
19
20
  // / non-global installs via HONE_CLI_BIN (e.g. a path to hone-cli.js run with node).
@@ -32,7 +33,8 @@ function readHoneConfig() {
32
33
  let rc = {};
33
34
  try { rc = JSON.parse(fsSync.readFileSync(path.join(os.homedir(), '.honerc'), 'utf8')) || {}; } catch { rc = {}; }
34
35
  const token = process.env.HONE_TOKEN || rc.token || null;
35
- const apiUrl = process.env.HONE_API || rc.api || 'https://api.hone.ai';
36
+ // HC-GF-396: same resolver as getConfig(); its one warning goes to stderr, never the stdio transport.
37
+ const { apiUrl } = resolveApiUrl({ env: process.env.HONE_API, rc: rc.api });
36
38
  return { apiUrl, hasToken: !!token };
37
39
  }
38
40
 
@@ -164,7 +166,8 @@ async function stepInstruction({ runId, cwd } = {}) {
164
166
  if (!runId) return { isError: true, detail: 'runId is required' };
165
167
  const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
166
168
  if (bad) return bad;
167
- const r = await runHone(['step-instruction', String(runId), '--format', 'json'], { cwd });
169
+ // Through _internal, as submitStep is, so the served-contract wording is tested without spawning `hone`.
170
+ const r = await module.exports._internal.runHone(['step-instruction', String(runId), '--format', 'json'], { cwd });
168
171
  if (r.error) return { isError: true, detail: r.error };
169
172
  const data = parseLastJson(r.stdout);
170
173
  return {
@@ -173,8 +176,12 @@ async function stepInstruction({ runId, cwd } = {}) {
173
176
  stepKey: data ? data.stepKey : null,
174
177
  contractVersion: data ? data.contractVersion : null,
175
178
  exitCode: r.exitCode,
179
+ // HC-GF-399: the submit the SERVED contract asks for. submitStep defaults contractVersion to '1', so under 2 and 3
180
+ // the agent must be told to pass fromTree and the version, or its submission is refused.
176
181
  detail: data
177
- ? `instruction for ${data.stepKey} (contract ${data.contractVersion}) — execute it and call hone_submit_step with the complete output`
182
+ ? (String(data.contractVersion) === '1'
183
+ ? `instruction for ${data.stepKey} (contract 1) — execute it and call hone_submit_step with the complete output`
184
+ : `instruction for ${data.stepKey} (contract ${data.contractVersion}) — write the files with your own tools, then call hone_submit_step with fromTree: true and contractVersion: '${data.contractVersion}'`)
178
185
  : (r.stderr || r.stdout || 'could not fetch instruction').slice(0, 500),
179
186
  };
180
187
  }
@@ -199,6 +206,11 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
199
206
  if (!fromTree && !hasOutput) {
200
207
  return { isError: true, detail: 'output is required (the complete step output text), or set fromTree' };
201
208
  }
209
+ // HC-GF-399: as the CLI refuses `--contract 3` without `--from-tree` — region edits proven against HEAD exist
210
+ // only in a render from the tree.
211
+ if (String(contractVersion) === '3' && !fromTree) {
212
+ return { isError: true, detail: 'contract 3 is a from-tree contract: write the files with your own tools and set fromTree: true' };
213
+ }
202
214
  // Before the temp file: a refused id writes nothing.
203
215
  const bad = refused([
204
216
  { label: 'runId', kind: 'runId', value: String(runId) },
@@ -486,14 +498,14 @@ const TOOLS = [
486
498
  },
487
499
  {
488
500
  name: 'hone_step_instruction',
489
- description: 'Fetch the editor-mode instruction for a Hone run that is awaiting the editor (execution.mode: editor). Read-only. Execute the returned instruction in THIS session, then call hone_submit_step with the complete output.',
501
+ description: 'Fetch the editor-mode instruction for a Hone run that is awaiting the editor (execution.mode: editor). Read-only. Execute the returned instruction in THIS session, then call hone_submit_step the way its result says: with the complete output under contract 1, or with fromTree: true and its contractVersion under contracts 2 and 3.',
490
502
  readOnly: true,
491
503
  inputSchema: { type: 'object', properties: { runId: { type: 'string' } }, required: ['runId'] },
492
504
  handler: stepInstruction,
493
505
  },
494
506
  {
495
507
  name: 'hone_submit_step',
496
- description: "Submit the editor-executed output for a Hone step that was fetched via hone_step_instruction. The output enters Hone's NORMAL gate + verification flow (judged by real test execution, not trust).",
508
+ description: "Submit the editor-executed output for a Hone step that was fetched via hone_step_instruction. Pass the contractVersion it was served; under contracts 2 and 3 set fromTree: true instead of output. The output enters Hone's NORMAL gate + verification flow (judged by real test execution, not trust).",
497
509
  readOnly: false,
498
510
  inputSchema: {
499
511
  type: 'object',
@@ -27,10 +27,15 @@
27
27
  // U+061C ARABIC LETTER MARK is the third bidi mark beside LRM/RLM (review round 3 F3). Written as
28
28
  // \u escapes: a literal bidi character in this source would reorder how THIS file reads.
29
29
  // eslint-disable-next-line no-control-regex -- the whole point is to match control chars.
30
- const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
31
-
30
+ const TERMINAL_UNSAFE_RE = /[\x00-\x1f\x7f-\x9f\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff\u{e0000}-\u{e007f}]/gu;
31
+ // A line break or tab is a space, so two words do not join; the characters above draw nothing yet change what a reader sees
32
+ // (zero-width, soft hyphen, BOM, the Unicode tag block); a lone surrogate is not a character a terminal or JSON can carry.
33
+ // Mirrors server/src/services/architect-consult.js scrubForTerminal: the two packages cannot share code, and a test pins them equal.
32
34
  function scrub(text) {
33
- return String(text == null ? '' : text).replace(TERMINAL_UNSAFE_RE, '');
35
+ return String(text == null ? '' : text)
36
+ .replace(/[\t\n\v\f\r\u0085\u2028\u2029]+/g, ' ')
37
+ .replace(TERMINAL_UNSAFE_RE, '')
38
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '\ufffd');
34
39
  }
35
40
 
36
41
  /**
@@ -91,4 +96,4 @@ function gateQuestionLines(status, stepKey) {
91
96
  ];
92
97
  }
93
98
 
94
- module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase };
99
+ module.exports = { normalizeQuestion, questionLines, gateQuestionLines, countPhrase, scrub };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ /**
3
+ * path-refusal.js — HC-GF-398: the CLI's one path classifier, with two named modes.
4
+ *
5
+ * There used to be two, and they disagreed. `isSecretPath()` (bundle-context) decided what is sent as whole
6
+ * content unasked — the step_4 `--from-tree` artifact, the bundle, the review diff — and `contentRefusal()`
7
+ * (context-reserve) decided what an agent may request. Only the second knew `*.key`, `*.pfx`, `*.jks`,
8
+ * `*.keystore` and `*.p8`, so a changed `keys/deploy.key` was refused as context and sent whole as a build
9
+ * artifact.
10
+ *
11
+ * egress what is never sent as whole content unasked. The credential basenames plus the five key-container
12
+ * extensions. A template (`.example`, `.sample`, `.template`, `.dist`) is exempt here, and only here.
13
+ * It stays narrow on purpose: a false positive silently drops a real change from the PR, so a name
14
+ * that merely says "secret" (`src/secret-manager.js`) or a hidden path (`.github/workflows/ci.yml`)
15
+ * is sent.
16
+ * content what an agent may not read. Egress, plus the wide rules: any name with secret or credential in
17
+ * it, an unanchored `id_rsa`-style name, any hidden file or directory, and a path that is not a
18
+ * plain relative path. It is built from egress, so it refuses everything egress refuses.
19
+ *
20
+ * Requires only `path`: bundle-context and tree-artifact load it, and tree-artifact's dependency list is kept
21
+ * small on purpose. The exported names avoid the words the call-site search treats as a held value.
22
+ */
23
+
24
+ const path = require('path');
25
+
26
+ const MODES = Object.freeze(['egress', 'content']);
27
+
28
+ // Egress: matched on the basename, so a nested `config/.env` is caught too.
29
+ const EGRESS_BASENAMES = [
30
+ /^\.env($|\.)/i, /^secrets?\./i, /\.pem$/i, /\.p12$/i, /(^|[._-])id_(rsa|dsa|ecdsa|ed25519)$/i,
31
+ /\.(key|pfx|jks|keystore|p8)$/i,
32
+ ];
33
+ const TEMPLATE_SUFFIX = /\.(example|sample|template|dist)$/i;
34
+
35
+ // Content only: credential-looking names egress deliberately sends.
36
+ const WIDE_BASENAMES = [/(^|[._-])id_(rsa|dsa|ecdsa|ed25519)/i, /\.(key|pfx|jks|keystore|p8)$/i, /(secret|credential)/i];
37
+
38
+ const CREDENTIAL = 'looks like a credential file';
39
+
40
+ function basenameOf(p) {
41
+ return path.posix.basename(String(p || '').replace(/\\/g, '/'));
42
+ }
43
+
44
+ function egressRefusal(p) {
45
+ const base = basenameOf(p);
46
+ if (TEMPLATE_SUFFIX.test(base)) return null;
47
+ return EGRESS_BASENAMES.some((re) => re.test(base)) ? CREDENTIAL : null;
48
+ }
49
+
50
+ /** Why a path is not a plain relative path, or null. `file` has no `:line` or `:symbol` suffix. */
51
+ function structuralRefusal(file) {
52
+ if (typeof file !== 'string' || file === '') return 'empty';
53
+ if (/^[-/~]/.test(file)) return 'absolute, home-relative or option-like';
54
+ if (/[\\\0]/.test(file)) return 'not a plain relative path';
55
+ if (file.split('/').some((s) => s === '' || s === '.' || s === '..')) return 'not a plain relative path';
56
+ return null;
57
+ }
58
+
59
+ function contentModeRefusal(file) {
60
+ const plain = structuralRefusal(file);
61
+ if (plain) return plain;
62
+ if (egressRefusal(file) || WIDE_BASENAMES.some((re) => re.test(path.posix.basename(file)))) return CREDENTIAL;
63
+ if (file.split('/').some((s) => s.startsWith('.'))) return 'a hidden file or directory';
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Why `p` is refused in `mode`, or null.
69
+ * @param {string} p a repository-relative path, no `:line` suffix
70
+ * @param {{mode: 'egress'|'content'}} opts required; an unknown or missing mode throws
71
+ * @returns {string|null}
72
+ */
73
+ function pathRefusal(p, { mode } = {}) {
74
+ if (mode === 'egress') return egressRefusal(p);
75
+ if (mode === 'content') return contentModeRefusal(p);
76
+ throw new Error(`pathRefusal: unknown mode ${JSON.stringify(mode)}; expected one of ${MODES.join(', ')}`);
77
+ }
78
+
79
+ module.exports = { pathRefusal, structuralRefusal, MODES };