@hone-ai/cli 1.41.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.
- package/README.md +7 -0
- package/hone-cli.js +169 -50
- package/lib/admin-request.js +49 -0
- package/lib/block-grammar.js +35 -0
- package/lib/bundle-context.js +7 -12
- package/lib/call-sites.js +4 -3
- package/lib/context-reserve.js +2 -18
- package/lib/default-api.js +109 -0
- package/lib/editor-park.js +8 -2
- package/lib/materialize-diff.js +32 -13
- package/lib/mcp-tools.js +17 -5
- package/lib/path-refusal.js +79 -0
- package/lib/review-diff-check.js +262 -0
- package/lib/review-diff.js +2 -2
- package/lib/run-report.js +75 -20
- package/lib/tree-artifact.js +425 -73
- package/package.json +1 -1
package/lib/context-reserve.js
CHANGED
|
@@ -31,7 +31,7 @@ const fs = require('node:fs');
|
|
|
31
31
|
const path = require('node:path');
|
|
32
32
|
const { remedyFor, partitionSafePaths, tokenOf, isSafeToken } = require('./hard-pause-remedy');
|
|
33
33
|
const { parseIncludeTarget } = require('./include-paths');
|
|
34
|
-
const {
|
|
34
|
+
const { pathRefusal, structuralRefusal } = require('./path-refusal');
|
|
35
35
|
const { isBatchId } = require('./run-target');
|
|
36
36
|
|
|
37
37
|
/** The start of the reason a re-served run is killed with. The loop reads it back to find the new batch. */
|
|
@@ -67,18 +67,6 @@ function hardPauseRequest(run) {
|
|
|
67
67
|
return remedy && remedy.stepKey === at[1] ? remedy : null;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Credential-looking names the bundler's own refusal (isSecretPath) does not cover: a public half of a key pair,
|
|
71
|
-
// key containers, anything the author named a secret or a credential.
|
|
72
|
-
const SECRET_LOOKING = [/(^|[._-])id_(rsa|dsa|ecdsa|ed25519)/i, /\.(key|pfx|jks|keystore|p8)$/i, /(secret|credential)/i];
|
|
73
|
-
|
|
74
|
-
/** Why a path is not a plain relative path, or null. `file` has no `:line` or `:symbol` suffix. */
|
|
75
|
-
function structuralRefusal(file) {
|
|
76
|
-
if (typeof file !== 'string' || file === '') return 'empty';
|
|
77
|
-
if (/^[-/~]/.test(file)) return 'absolute, home-relative or option-like';
|
|
78
|
-
if (/[\\\0]/.test(file)) return 'not a plain relative path';
|
|
79
|
-
if (file.split('/').some((s) => s === '' || s === '.' || s === '..')) return 'not a plain relative path';
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
70
|
|
|
83
71
|
/**
|
|
84
72
|
* Why the CONTENT of a path (a plain file path, no `:line`) must not be sent to a model, or null: the three
|
|
@@ -87,11 +75,7 @@ function structuralRefusal(file) {
|
|
|
87
75
|
* @param {string} file
|
|
88
76
|
*/
|
|
89
77
|
function contentRefusal(file) {
|
|
90
|
-
|
|
91
|
-
if (plain) return plain;
|
|
92
|
-
if (isSecretPath(file) || SECRET_LOOKING.some((re) => re.test(path.posix.basename(file)))) return 'looks like a credential file';
|
|
93
|
-
if (file.split('/').some((s) => s.startsWith('.'))) return 'a hidden file or directory';
|
|
94
|
-
return null;
|
|
78
|
+
return pathRefusal(file, { mode: 'content' });
|
|
95
79
|
}
|
|
96
80
|
|
|
97
81
|
/**
|
|
@@ -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
|
+
};
|
package/lib/editor-park.js
CHANGED
|
@@ -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
|
|
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');
|
package/lib/materialize-diff.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
276
|
-
*
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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',
|
|
@@ -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 };
|