@hone-ai/cli 1.34.0 → 1.35.1
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/bin/hone-mcp.js +1 -0
- package/hone-cli.js +1341 -795
- package/lib/agent-eval-pacing.js +102 -0
- package/lib/agent-eval-probes-boundary.js +0 -0
- package/lib/agent-eval-probes-stateful.js +72 -0
- package/lib/agent-eval-score.js +70 -0
- package/lib/agent-executor.js +26 -4
- package/lib/asset-fetch-list.js +72 -0
- package/lib/auto-verify.js +67 -3
- package/lib/batch-follow.js +333 -0
- package/lib/block-grammar.js +126 -0
- package/lib/bundle-context.js +43 -4
- package/lib/bundle-paths.js +698 -1
- package/lib/bundle-source-dirs.js +79 -0
- package/lib/doctor-bind-default.js +192 -18
- package/lib/doctor-local-run.js +139 -1
- package/lib/emit-pr.js +39 -6
- package/lib/git-helpers.js +19 -8
- package/lib/hard-pause-remedy.js +101 -0
- package/lib/include-paths.js +155 -0
- package/lib/learnings-parse.js +97 -7
- package/lib/materialize-diff.js +131 -73
- package/lib/mcp-tools.js +20 -14
- package/lib/pipeline-config.js +182 -0
- package/lib/private-temp.js +52 -0
- package/lib/promote-payload.js +77 -0
- package/lib/refresh-knowledge.js +15 -6
- package/lib/repo-tree.js +22 -3
- package/lib/run-context.js +698 -0
- package/lib/run-target.js +66 -1
- package/lib/sandbox-lifecycle.js +357 -0
- package/lib/schedule-cron.js +1 -1
- package/lib/skill-assertions.js +61 -7
- package/lib/skill-audit-render.js +1 -1
- package/lib/story-classifier-extract.js +16 -8
- package/lib/story-classifier.js +64 -0
- package/lib/story-compose.js +69 -0
- package/lib/story-line.js +40 -0
- package/lib/story-provenance.js +267 -0
- package/lib/timeout-option.js +42 -0
- package/lib/untrusted-code-env.js +51 -0
- package/lib/verdict-env.js +31 -0
- package/lib/verify-patch.js +74 -4
- package/lib/verify-pr.js +2 -1
- package/lib/worktree-provision.js +62 -2
- package/package.json +1 -1
package/lib/story-classifier.js
CHANGED
|
@@ -53,6 +53,12 @@ const ARCHITECT_AXES = Object.freeze({
|
|
|
53
53
|
// story with single-module surface + one obvious design engaged NO axis —
|
|
54
54
|
// exactly where an independent architect caught a money leak.
|
|
55
55
|
SHARED_CHOKEPOINT: 'shared-chokepoint',
|
|
56
|
+
// HC-GF-173: the story's acceptance cannot be verified without stored state — a table, cache,
|
|
57
|
+
// queue or counter. This is the trigger for the sandbox capability (HC-GF-168/169), which merged
|
|
58
|
+
// with nothing invoking it. Deliberately NOT engaged where real inputs already exist: a diff and
|
|
59
|
+
// a file are real data, and seeding there tests the fixture author's imagination.
|
|
60
|
+
// Additive-only, exactly as SHARED_CHOKEPOINT was: absent or false is today's behaviour.
|
|
61
|
+
STATEFUL: 'stateful',
|
|
56
62
|
});
|
|
57
63
|
|
|
58
64
|
// Estimate ordering for threshold comparisons
|
|
@@ -107,9 +113,65 @@ function shouldEngageArchitect(input) {
|
|
|
107
113
|
if (input.touches_shared_chokepoint === true) {
|
|
108
114
|
axes.push(ARCHITECT_AXES.SHARED_CHOKEPOINT);
|
|
109
115
|
}
|
|
116
|
+
// Stored state → architect for fixture + isolation review (HC-GF-173). Strict `true` so a
|
|
117
|
+
// truthy string cannot enrol a story by accident.
|
|
118
|
+
if (input.requires_stored_state === true) {
|
|
119
|
+
axes.push(ARCHITECT_AXES.STATEFUL);
|
|
120
|
+
}
|
|
110
121
|
return { engage: axes.length > 0, axes };
|
|
111
122
|
}
|
|
112
123
|
|
|
124
|
+
/**
|
|
125
|
+
* HC-GF-173: a story classified stateful with no declared sandbox must SAY SO.
|
|
126
|
+
*
|
|
127
|
+
* Silent skipping is how `pipeline-integrity` became 195 learnings pointed at nothing — the
|
|
128
|
+
* capability existed, nothing invoked it, and no output said so. Returns null when there is
|
|
129
|
+
* nothing to report, because a warning printed on every story is one nobody reads.
|
|
130
|
+
*
|
|
131
|
+
* @param {{axes?: string[], sandbox?: object|null}} input
|
|
132
|
+
* @returns {string|null}
|
|
133
|
+
*/
|
|
134
|
+
function describeSandboxReadiness({ axes = [], sandbox = null } = {}) {
|
|
135
|
+
if (!Array.isArray(axes) || !axes.includes(ARCHITECT_AXES.STATEFUL)) return null;
|
|
136
|
+
if (sandbox && typeof sandbox === 'object') return null;
|
|
137
|
+
return 'this story is classified `stateful` — its acceptance needs stored state — but no '
|
|
138
|
+
+ '`sandbox:` block is declared in `.pipeline-config.yml`. The pipeline cannot seed a scratch '
|
|
139
|
+
+ 'datastore for it, so any test it writes will assert against whatever the environment '
|
|
140
|
+
+ 'happens to hold. Declare `sandbox:` (create/migrate/reset plus a `verify` block) or '
|
|
141
|
+
+ 'reclassify the story.';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A one-shot readiness reporter — HC-GF-189.
|
|
147
|
+
*
|
|
148
|
+
* The follow loop re-observes the same run state every few seconds, so the note has to fire once;
|
|
149
|
+
* and "once" is behaviour, not text. An earlier version of this lived inline in the poll loop with
|
|
150
|
+
* a source-grep test, and mutation walked straight through it: `if (false) { console.warn(…) }` and
|
|
151
|
+
* `if (true) {` both kept every asserted identifier in the file. A closure with a returned decision
|
|
152
|
+
* can be driven directly instead.
|
|
153
|
+
*
|
|
154
|
+
* @param {{readSandbox: () => object|null}} deps reads the adopter's declaration; injected so the
|
|
155
|
+
* decision is testable without a repo on disk
|
|
156
|
+
* @returns {(config: object|null) => string|null} the note the FIRST time there is one, null after
|
|
157
|
+
*/
|
|
158
|
+
function makeReadinessReporter({ readSandbox }) {
|
|
159
|
+
let reported = false;
|
|
160
|
+
return (config) => {
|
|
161
|
+
if (reported) return null;
|
|
162
|
+
let note = null;
|
|
163
|
+
try {
|
|
164
|
+
note = describeSandboxReadiness({
|
|
165
|
+
axes: (config && config.classifier_axes) || [],
|
|
166
|
+
sandbox: readSandbox(),
|
|
167
|
+
});
|
|
168
|
+
} catch { return null; } // reporting must never break a run
|
|
169
|
+
if (!note) return null;
|
|
170
|
+
reported = true;
|
|
171
|
+
return note;
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
113
175
|
// ─── Pass 2: Track determination (priority order) ───
|
|
114
176
|
|
|
115
177
|
function determineTrack(input, thresholds) {
|
|
@@ -281,6 +343,8 @@ function classifyStory(input, thresholds) {
|
|
|
281
343
|
}
|
|
282
344
|
|
|
283
345
|
module.exports = {
|
|
346
|
+
describeSandboxReadiness,
|
|
347
|
+
makeReadinessReporter,
|
|
284
348
|
classifyStory,
|
|
285
349
|
shouldEngageArchitect,
|
|
286
350
|
determineTrack,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-232 — fold the sibling rows the server resolved into the story text step_0 sees.
|
|
4
|
+
*
|
|
5
|
+
* `GET /stories/:id/context` returns `{ description, referencedPaths, referencedStories }`.
|
|
6
|
+
* The CLI took `description` and dropped the rest, so `referencedStories` appeared 6 times in
|
|
7
|
+
* the server and 0 times in the entire CLI — HC-GF-215's server half could satisfy its
|
|
8
|
+
* AC-1..AC-6 and never its AC-7, which is the criterion that story exists to prove.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS FOLDS INTO `story_description` RATHER THAN A NEW CONFIG FIELD. The orchestrator
|
|
11
|
+
* renders `run.config.story_description` as `# Story Description`, and `computeContextHash`
|
|
12
|
+
* hashes that same string. A separate field would leave the hash unchanged, so the second run
|
|
13
|
+
* of the A/B would replay the first's cached step_0 artifact and the experiment could not be
|
|
14
|
+
* run at all. HC-GF-231 put the code bundle into that hash for the same reason.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS MUST NOT REACH. `issueBodyForFiles`, which drives path extraction. The server's own
|
|
17
|
+
* contract states `referencedStories` carries PROSE ONLY and a sibling's `referencedPaths` are
|
|
18
|
+
* deliberately not bundled. Composing into the path-extraction input would bundle files the
|
|
19
|
+
* story never named — HC-GF-229's defect arriving by a different road.
|
|
20
|
+
*
|
|
21
|
+
* Pure, synchronous, no I/O.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The section marker. Exported so the test pins the same literal the composer writes, and so a
|
|
26
|
+
* future reader can scope anything that must see only the primary story — `renamedContracts`
|
|
27
|
+
* (`server/src/services/story-conformance.js`) derives its identifier set from this text, and
|
|
28
|
+
* sibling prose widens that set in both directions.
|
|
29
|
+
*/
|
|
30
|
+
const RELATED_HEADING = '## Related stories (resolved from [[links]] in this row)';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Append the resolved siblings to a story description.
|
|
34
|
+
*
|
|
35
|
+
* Returns the description UNCHANGED when there is nothing to add. That is not a micro-
|
|
36
|
+
* optimisation: it is the acceptance's second half. Appending an empty section to every
|
|
37
|
+
* unlinked story would change its hash, invalidate its grooming cache, and re-bill step_0
|
|
38
|
+
* across the whole backlog for no gain.
|
|
39
|
+
*
|
|
40
|
+
* Fails open on a malformed entry, matching the server, which uses `continue` plus a
|
|
41
|
+
* diagnostic on every sibling failure path and never throws: a story whose sibling is
|
|
42
|
+
* unresolvable still runs, it just runs with less. An entry needs BOTH an id and prose to be
|
|
43
|
+
* useful — prose with no id cannot be cited by the agent, and an id with no prose adds a name
|
|
44
|
+
* and no information — so anything else is skipped rather than half-rendered.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} description the primary story row's text
|
|
47
|
+
* @param {Array<{storyId: string, description: string}>} [referencedStories]
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
function composeStoryDescription(description, referencedStories) {
|
|
51
|
+
const primary = typeof description === 'string' ? description : '';
|
|
52
|
+
if (!Array.isArray(referencedStories) || referencedStories.length === 0) return primary;
|
|
53
|
+
|
|
54
|
+
const parts = [];
|
|
55
|
+
for (const s of referencedStories) {
|
|
56
|
+
if (!s || typeof s !== 'object') continue;
|
|
57
|
+
const id = typeof s.storyId === 'string' ? s.storyId.trim() : '';
|
|
58
|
+
const prose = typeof s.description === 'string' ? s.description.trim() : '';
|
|
59
|
+
if (!id || !prose) continue;
|
|
60
|
+
parts.push(`### ${id}\n\n${prose}`);
|
|
61
|
+
}
|
|
62
|
+
if (parts.length === 0) return primary;
|
|
63
|
+
|
|
64
|
+
// Order is the server's resolution order, preserved deliberately: the composed string is
|
|
65
|
+
// hashed, so re-ordering identical inputs would invalidate a cache entry at random.
|
|
66
|
+
return `${primary}\n\n${RELATED_HEADING}\n\n${parts.join('\n\n')}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { composeStoryDescription, RELATED_HEADING };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* story-line.js — one line of a `hone queue-stories --file` list.
|
|
4
|
+
*
|
|
5
|
+
* `STORY-ID` or `STORY-ID depends:DEP1,DEP2`. The `depends:` token is case-sensitive and must come
|
|
6
|
+
* after the storyId; multiple deps are comma-separated and whitespace tolerant. A line with no
|
|
7
|
+
* `depends:` yields no `dependsOn` at all — the server validates absence differently from empty.
|
|
8
|
+
*
|
|
9
|
+
* Extracted by HC-GF-140 stage 2. The regex was pinned by its SOURCE TEXT
|
|
10
|
+
* (`assert.match(cliSrc, /\^\(\\S\+\)\\s\+depends:\(\\S\+\)\\s\*\$/)`), which is the worst shape a
|
|
11
|
+
* pin takes: it freezes the regex's SPELLING, so an equivalent rewrite fails the test while a
|
|
12
|
+
* behavioural change that keeps the spelling passes. What the parser must do is describable
|
|
13
|
+
* directly, so it is asserted directly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A line that is neither blank nor a comment. */
|
|
17
|
+
function isStoryLine(line) {
|
|
18
|
+
const t = String(line).trim();
|
|
19
|
+
return Boolean(t) && !t.startsWith('#');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} line
|
|
24
|
+
* @returns {{storyId: string, dependsOn?: string[]}|{error: string}}
|
|
25
|
+
* `error` for a line the caller must reject — a storyId followed by anything unrecognised.
|
|
26
|
+
* Silently treating `STORY-A something` as `STORY-A` loses the operator's intent.
|
|
27
|
+
*/
|
|
28
|
+
function parseStoryLine(line) {
|
|
29
|
+
const t = String(line).trim();
|
|
30
|
+
const m = t.match(/^(\S+)\s+depends:(\S+)\s*$/);
|
|
31
|
+
if (m) {
|
|
32
|
+
return { storyId: m[1], dependsOn: m[2].split(',').map(s => s.trim()).filter(Boolean) };
|
|
33
|
+
}
|
|
34
|
+
if (/\s/.test(t)) {
|
|
35
|
+
return { error: `Malformed line in --file: "${t}"` };
|
|
36
|
+
}
|
|
37
|
+
return { storyId: t };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { parseStoryLine, isStoryLine };
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* story-provenance.js — HC-GF-270: is the text this run was built from something Hone
|
|
4
|
+
* may EXECUTE unattended?
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS. `hone run-story <issue-number>` takes story text from a GitHub issue
|
|
7
|
+
* (cli/hone-cli.js, the `fetched GitHub issue #` site), the pipeline turns that text into a
|
|
8
|
+
* patch, and auto-verify (HC-GF-064) then RUNS that patch — the provisioning install first
|
|
9
|
+
* (cli/lib/worktree-provision.js) — as the operator, with `$HOME` and the network, the
|
|
10
|
+
* moment the run pauses. HC-GF-269 removed Hone's own credentials from the child's
|
|
11
|
+
* environment; it did not remove `~/.aws`, `~/.ssh`, `~/.npmrc` or the network from its
|
|
12
|
+
* REACH, because the child still runs as the operator. That is the fork-PR problem on a
|
|
13
|
+
* path GitHub's fork-PR secret withholding never sees, because no PR exists yet.
|
|
14
|
+
*
|
|
15
|
+
* TRUST IS DERIVED IN EXACTLY ONE PLACE — here. The callers (the poll-loop decider,
|
|
16
|
+
* verify-patch, emit-pr) record and pass RAW FACTS and never re-derive the decision, so a
|
|
17
|
+
* change to the trusted set is one edit and one mutation, not four
|
|
18
|
+
* (HC-GF-070/074/106: route every site of a decision through one function).
|
|
19
|
+
*
|
|
20
|
+
* PURE. No filesystem, no network, no process. Never throws: a malformed `source` resolves
|
|
21
|
+
* to untrusted, mirroring auto-verify.js's "a malformed config must not break the run".
|
|
22
|
+
*
|
|
23
|
+
* Design: .github/pipeline/HC-GF-270/architect.md §1 and §3.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `author_association` values trusted to auto-verify: the repository's owner, members of its
|
|
28
|
+
* organization, and its collaborators. NOT the same as "has write access" — MEMBER includes org
|
|
29
|
+
* members whose base role is none or read, and COLLABORATOR includes outside collaborators with
|
|
30
|
+
* read or triage (security review MEDIUM-1; a write-permission check is HC-GF-282). Everything
|
|
31
|
+
* outside the set is untrusted, INCLUDING values GitHub adds later:
|
|
32
|
+
* `CONTRIBUTOR` (merged a PR once, which is not write access), `FIRST_TIME_CONTRIBUTOR`,
|
|
33
|
+
* `FIRST_TIMER`, `MANNEQUIN`, `NONE`, and deleted users (`ghost`, reported as `NONE`).
|
|
34
|
+
*/
|
|
35
|
+
const TRUSTED_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
|
36
|
+
|
|
37
|
+
/** Story text the operator typed or pointed at. Not someone else's words. */
|
|
38
|
+
const OPERATOR_AUTHORED_KINDS = new Set(['file', 'description']);
|
|
39
|
+
|
|
40
|
+
const ISSUE_ID_RE = /^\d+$/;
|
|
41
|
+
/** A hostname, not an SSH alias: at least one dot, and no path/user/port parts. */
|
|
42
|
+
const DOTTED_DOMAIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9-]+)+$/;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An all-digits story id is the ONLY id shape that could have reached the issue fetch.
|
|
46
|
+
* This is the whole legacy rule: runs created before HC-GF-270 carry no `story_source`,
|
|
47
|
+
* and this is the only evidence left about where their text came from.
|
|
48
|
+
*/
|
|
49
|
+
function isIssueShapedId(storyId) {
|
|
50
|
+
return ISSUE_ID_RE.test(String(storyId == null ? '' : storyId).trim());
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Is this a HOSTNAME we may compare against `html_url`'s host?
|
|
55
|
+
*
|
|
56
|
+
* Binding decision 2 (operator, step_1 gate of run fa6e00c4): compare hosts ONLY when the
|
|
57
|
+
* remote's host is a dotted domain — the GitHub Enterprise case. An SSH host alias such as
|
|
58
|
+
* `github-work` from `~/.ssh/config` is not a hostname, and treating it as one would make
|
|
59
|
+
* every collaborator's issue untrusted for every operator who uses one. That false
|
|
60
|
+
* fail-closed is the version of this feature that gets reverted.
|
|
61
|
+
*/
|
|
62
|
+
function isDottedDomain(host) {
|
|
63
|
+
const h = String(host == null ? '' : host).trim();
|
|
64
|
+
return h !== '' && DOTTED_DOMAIN_RE.test(h);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The host of a git remote URL, or null when there isn't one (a local path).
|
|
69
|
+
* Handles `git@host:owner/repo.git`, `ssh://git@host/owner/repo`, `https://host/owner/repo`.
|
|
70
|
+
*/
|
|
71
|
+
function remoteHostFromUrl(remoteUrl) {
|
|
72
|
+
const host = rawRemoteHost(remoteUrl);
|
|
73
|
+
// GitHub's documented SSH-over-HTTPS endpoint (`ssh://git@ssh.github.com:443/…`) serves
|
|
74
|
+
// github.com's repositories. Compared as-is it would refuse every collaborator's issue for an
|
|
75
|
+
// operator behind a firewall that blocks port 22 (security review LOW-3).
|
|
76
|
+
return host === 'ssh.github.com' ? 'github.com' : host;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rawRemoteHost(remoteUrl) {
|
|
80
|
+
const s = String(remoteUrl == null ? '' : remoteUrl).trim();
|
|
81
|
+
if (!s) return null;
|
|
82
|
+
const scp = s.match(/^[^/@\s]+@([^/:\s]+):/);
|
|
83
|
+
if (scp) return scp[1].toLowerCase();
|
|
84
|
+
try {
|
|
85
|
+
const u = new URL(s);
|
|
86
|
+
if (u.hostname) return u.hostname.toLowerCase();
|
|
87
|
+
} catch { /* not a URL — fall through */ }
|
|
88
|
+
const bare = s.match(/^([A-Za-z0-9][A-Za-z0-9._-]*):/);
|
|
89
|
+
return bare ? bare[1].toLowerCase() : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseUrl(u) {
|
|
93
|
+
try { return new URL(String(u)); } catch { return null; }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A value named in a reason string, without ever throwing on it. */
|
|
97
|
+
function describe(v) {
|
|
98
|
+
if (v === undefined) return 'undefined';
|
|
99
|
+
if (v === null) return 'null';
|
|
100
|
+
if (typeof v === 'string') return JSON.stringify(v);
|
|
101
|
+
if (typeof v === 'object') { try { return JSON.stringify(v); } catch { return String(v); } }
|
|
102
|
+
return String(v);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* `owner/repo#number` when we have it, so every reason names WHICH issue.
|
|
107
|
+
*
|
|
108
|
+
* HC-GF-270 code review (LOW-6): `ref` is now ALWAYS `{owner, repo, number}` or null, for
|
|
109
|
+
* every kind — the unified-return-shape rule. A human-readable name for a non-issue source
|
|
110
|
+
* (`story.md`, `--description`, `HC-GF-270`) travels in its own `label` field, so no reader
|
|
111
|
+
* has to branch on `typeof source.ref` and a sixth kind cannot get that branch wrong.
|
|
112
|
+
*/
|
|
113
|
+
function refLabel(source) {
|
|
114
|
+
const r = source && source.ref;
|
|
115
|
+
if (r && typeof r === 'object' && !Array.isArray(r) && r.owner && r.repo && r.number != null) {
|
|
116
|
+
return `${r.owner}/${r.repo}#${r.number}`;
|
|
117
|
+
}
|
|
118
|
+
const label = source && source.label;
|
|
119
|
+
if (typeof label === 'string' && label.trim() !== '') return label.trim();
|
|
120
|
+
return '(unidentified issue)';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The operator-facing name of a source, for kinds that have no owner/repo/number. */
|
|
124
|
+
function sourceLabel(source, fallback) {
|
|
125
|
+
const label = source && source.label;
|
|
126
|
+
if (typeof label === 'string' && label.trim() !== '') return label.trim();
|
|
127
|
+
return fallback;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The ONLY `html_url` path this run's issue may have. Transfers and redirects change it. */
|
|
131
|
+
function expectedIssuePath(ref) {
|
|
132
|
+
if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return null;
|
|
133
|
+
const { owner, repo, number } = ref;
|
|
134
|
+
if (!owner || !repo || number == null || String(number).trim() === '') return null;
|
|
135
|
+
// `run-story 007` is issue 7: GitHub's html_url carries no leading zeros (security review LOW-3).
|
|
136
|
+
const n = String(number).trim();
|
|
137
|
+
const num = /^\d+$/.test(n) ? String(Number(n)) : n;
|
|
138
|
+
return `/${String(owner).toLowerCase()}/${String(repo).toLowerCase()}/issues/${num}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {object|null|undefined} source config.story_source, as recorded at fetch time
|
|
143
|
+
* @param {string|number|null|undefined} storyId the run's story id (the legacy rule's input)
|
|
144
|
+
* @returns {{ trusted: boolean, label: string, reason: string }}
|
|
145
|
+
* `reason` is ALWAYS populated, including when trusted, because the callers print it: an
|
|
146
|
+
* operator told only "this run will not auto-verify" cannot tell a real refusal from a bug.
|
|
147
|
+
*/
|
|
148
|
+
function assessStorySource(source, storyId) {
|
|
149
|
+
const yes = (label, reason) => ({ trusted: true, label, reason });
|
|
150
|
+
const no = (label, reason) => ({ trusted: false, label, reason });
|
|
151
|
+
const id = String(storyId == null ? '' : storyId).trim() || '(unknown)';
|
|
152
|
+
// HC-GF-270 (security review BLOCKER-1): the two id-shape rules below read "not all digits" as
|
|
153
|
+
// "never reached the issue fetch". A MISSING id is not evidence of that — it is what a caller
|
|
154
|
+
// sees when the run fetch answered with something other than this run — so it is untrusted.
|
|
155
|
+
const idMissing = String(storyId == null ? '' : storyId).trim() === '';
|
|
156
|
+
|
|
157
|
+
// No `story_source` at all: a run created before this change, or a config that could not
|
|
158
|
+
// be read. Fails closed exactly where an issue could have been involved, and never pauses
|
|
159
|
+
// a roadmap run (architect memo §3).
|
|
160
|
+
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
|
161
|
+
if (idMissing) {
|
|
162
|
+
return no('legacy', 'this run recorded no story_source and no story id — nothing says where its text came from');
|
|
163
|
+
}
|
|
164
|
+
return isIssueShapedId(storyId)
|
|
165
|
+
? no('legacy', `this run recorded no story_source and its story id '${id}' is all digits — `
|
|
166
|
+
+ 'an issue number is the only id shape that could have reached the issue fetch, so its text may not be yours')
|
|
167
|
+
: yes('legacy', `this run recorded no story_source, and its story id '${id}' is not an issue number, `
|
|
168
|
+
+ 'so it never reached the issue fetch');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Any error recorded while establishing provenance is untrusted, and the reason carries the
|
|
172
|
+
// original text: "a gh failure pauses rather than proceeds" is only actionable if the
|
|
173
|
+
// operator can see WHICH failure (a missing token and a rate limit want different moves).
|
|
174
|
+
if (source.error !== undefined && source.error !== null && String(source.error).trim() !== '') {
|
|
175
|
+
return no('error', 'story provenance could not be established: '
|
|
176
|
+
+ String(source.error).split('\n')[0].slice(0, 200));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const kind = typeof source.kind === 'string' ? source.kind : '';
|
|
180
|
+
|
|
181
|
+
if (OPERATOR_AUTHORED_KINDS.has(kind)) {
|
|
182
|
+
const where = kind === 'file'
|
|
183
|
+
? `--story-file ${sourceLabel(source, '(unnamed file)')}`
|
|
184
|
+
: '--description';
|
|
185
|
+
return yes(kind, `the story text came from ${where} — you wrote it`);
|
|
186
|
+
}
|
|
187
|
+
if (kind === 'roadmap') {
|
|
188
|
+
return yes('roadmap', `the story text for ${sourceLabel(source, id)} came from the roadmap `
|
|
189
|
+
+ '— committed repository content');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Written by queue-stories before HC-GF-097. Since then queue-stories resolves each story's text
|
|
193
|
+
// through cli/lib/run-context.js and records its real source (roadmap, issue, ...), so this kind
|
|
194
|
+
// appears only on runs queued earlier, and is kept for them.
|
|
195
|
+
// Binding decision 1 (operator, step_1 gate of run fa6e00c4): queue-stories sends IDS and
|
|
196
|
+
// never fetches issue text, so only an all-digits id could have become issue text
|
|
197
|
+
// downstream. Any other id stays trusted — an overnight batch of roadmap ids must keep
|
|
198
|
+
// auto-verify, which is the entire point of running it overnight.
|
|
199
|
+
if (kind === 'id-only') {
|
|
200
|
+
const sent = sourceLabel(source, id);
|
|
201
|
+
if (idMissing) {
|
|
202
|
+
return no('id-only', 'queue-stories stamped this run but its story id is missing — nothing says which id was sent');
|
|
203
|
+
}
|
|
204
|
+
return isIssueShapedId(storyId)
|
|
205
|
+
? no('id-only', `queue-stories sent the id '${sent}' and it is all digits, so this run may have fetched issue text`)
|
|
206
|
+
: yes('id-only', `queue-stories sent the id '${sent}', which is not an issue number — no issue text was fetched`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (kind === 'issue') {
|
|
210
|
+
const assoc = source.association;
|
|
211
|
+
// Schema drift is untrusted, not trusted-by-accident: if GitHub renames or drops this
|
|
212
|
+
// field, a gate that reads `undefined` as "not in the untrusted list" opens silently.
|
|
213
|
+
if (typeof assoc !== 'string' || assoc.trim() === '') {
|
|
214
|
+
return no('issue', `issue ${refLabel(source)} reports no usable author_association `
|
|
215
|
+
+ `(${describe(assoc)}) — failing closed`);
|
|
216
|
+
}
|
|
217
|
+
const a = assoc.trim().toUpperCase();
|
|
218
|
+
if (!TRUSTED_ASSOCIATIONS.has(a)) {
|
|
219
|
+
return no('issue', `issue ${refLabel(source)} was authored by ${source.author || 'an unknown user'}, `
|
|
220
|
+
+ `whose author_association is ${a} — not one of ${[...TRUSTED_ASSOCIATIONS].join(', ')}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The path check runs in EVERY case, host alias or not: a transferred issue redirects,
|
|
224
|
+
// and a redirect is how a trusted association from one repository gets attached to text
|
|
225
|
+
// in another.
|
|
226
|
+
const url = parseUrl(source.html_url);
|
|
227
|
+
if (!url) {
|
|
228
|
+
return no('issue', `issue ${refLabel(source)} has no parsable html_url (${describe(source.html_url)}) — failing closed`);
|
|
229
|
+
}
|
|
230
|
+
const want = expectedIssuePath(source.ref);
|
|
231
|
+
if (!want) {
|
|
232
|
+
return no('issue', `story_source.ref does not name an owner, repo and number (${describe(source.ref)}) — `
|
|
233
|
+
+ 'the html_url could not be checked, so this run is not trusted');
|
|
234
|
+
}
|
|
235
|
+
if (url.pathname.toLowerCase().replace(/\/+$/, '') !== want) {
|
|
236
|
+
return no('issue', `html_url ${source.html_url} is not ${want} — the issue may have been transferred, `
|
|
237
|
+
+ 'or the request redirected to a different repository');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const remoteHost = source.remoteHost;
|
|
241
|
+
if (isDottedDomain(remoteHost)) {
|
|
242
|
+
const rh = String(remoteHost).trim().toLowerCase();
|
|
243
|
+
if (rh !== url.hostname.toLowerCase()) {
|
|
244
|
+
return no('issue', `the git remote's host ${rh} is not ${url.hostname.toLowerCase()} — the issue was read `
|
|
245
|
+
+ 'from a different GitHub host than this repository lives on, where someone else can own the same name');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return yes('issue', `issue ${refLabel(source)} was authored by ${source.author || 'an unknown user'} `
|
|
250
|
+
+ `with author_association ${a}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// An unrecognised kind is a newer writer, a typo, or a hand-edited config. None of those
|
|
254
|
+
// is evidence of trust.
|
|
255
|
+
return no('unknown', `story_source.kind ${describe(source.kind)} is not a kind this version recognises — failing closed`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = {
|
|
259
|
+
assessStorySource,
|
|
260
|
+
remoteHostFromUrl,
|
|
261
|
+
isIssueShapedId,
|
|
262
|
+
isDottedDomain,
|
|
263
|
+
refLabel,
|
|
264
|
+
sourceLabel,
|
|
265
|
+
TRUSTED_ASSOCIATIONS,
|
|
266
|
+
OPERATOR_AUTHORED_KINDS,
|
|
267
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* timeout-option.js — HC-GF-200. One refusal for every `--timeout` flag.
|
|
4
|
+
*
|
|
5
|
+
* `Number('abc')` is NaN and `Number(' ')` is 0, so a mistyped flag reaches a
|
|
6
|
+
* child_process call as NaN or as an instant kill. Node itself refuses the NaN
|
|
7
|
+
* with `RangeError [ERR_OUT_OF_RANGE]: ... It must be an integer. Received NaN`,
|
|
8
|
+
* which is safe — it throws rather than running unbounded — but it names node's
|
|
9
|
+
* internals instead of the flag the operator mistyped, and the zero case is not
|
|
10
|
+
* refused at all.
|
|
11
|
+
*
|
|
12
|
+
* Note for anyone carrying the older claim forward: this is NOT the
|
|
13
|
+
* `setTimeout(NaN)` hazard. `setTimeout(NaN)` fires on the next tick; a
|
|
14
|
+
* child_process `timeout` option throws. HC-GF-198's row said verify-patch
|
|
15
|
+
* shared the 1ms behaviour and could therefore produce a wrong merge verdict —
|
|
16
|
+
* measured 2026-09-19, it does not, and HC-GF-200 corrects that record.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string|number} raw what the operator typed
|
|
21
|
+
* @param {string} flag the flag name, for the message ('--timeout')
|
|
22
|
+
* @returns {number} seconds
|
|
23
|
+
* @throws {Error} naming the flag and echoing the value
|
|
24
|
+
*/
|
|
25
|
+
function parseTimeoutSeconds(raw, flag = '--timeout') {
|
|
26
|
+
// A blank string needs NO special case: Number('') and Number(' ') are both 0,
|
|
27
|
+
// and the `<= 0` below already refuses it. A first draft added an explicit
|
|
28
|
+
// trim guard for that input; mutation showed deleting it changed nothing,
|
|
29
|
+
// because it could not. A guard that cannot alter an outcome reads as
|
|
30
|
+
// protection and is only maintenance — HC-CI-030 records eight in this repo.
|
|
31
|
+
// The BEHAVIOUR is still pinned: the suite asserts a blank value is refused,
|
|
32
|
+
// which is the property, independent of which branch does the refusing.
|
|
33
|
+
const seconds = Number(raw);
|
|
34
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`${flag} must be a positive number of seconds (got ${JSON.stringify(raw)})`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return seconds;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { parseTimeoutSeconds };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* untrusted-code-env.js — HC-GF-269. The environment for code Hone did not write.
|
|
4
|
+
*
|
|
5
|
+
* verify-patch, emit-pr and verify-pr run a shell command in a tree that holds step_4's patch — an
|
|
6
|
+
* LLM's output, and its tests — and auto-verify (HC-GF-064) runs it the moment a run pauses, before
|
|
7
|
+
* anyone has read it. The worktree isolates the FILES. The environment was the operator's whole
|
|
8
|
+
* shell. Measured on local run 7a4b09b2 (2026-09-24): `tests/e2e/dashboard.smoke.test.js:27` read
|
|
9
|
+
* `process.env.HONE_TOKEN` inside `hone verify-patch`, received the operator's local dogfood token and
|
|
10
|
+
* sent it to production — the target `ci.e2e` pins — which answered 401. Story text can come from a
|
|
11
|
+
* GitHub issue, so whoever can file one can try to steer the builder into a test that sends it
|
|
12
|
+
* somewhere else.
|
|
13
|
+
*
|
|
14
|
+
* The rule: Hone's own namespace never reaches code Hone did not write. Every `HONE_*` variable goes,
|
|
15
|
+
* by PREFIX rather than by list — the CLI reads six credential or endpoint variables today, and a
|
|
16
|
+
* list is out of date the day a seventh is added. The adopter's own variables all stay: database
|
|
17
|
+
* URLs, cloud profiles, proxies, toolchain. The cost is Hone's own SETTINGS an adopter's CI may read
|
|
18
|
+
* (`HONE_SOURCE_DIRS`, docs/sdlc/setup.md): exported in the operator's shell they no longer reach the
|
|
19
|
+
* command, so set them in the command itself — `ci.local_command` or `--command`.
|
|
20
|
+
*
|
|
21
|
+
* Sibling to `git-env.js` (variables that select a REPOSITORY) and `verdict-env.js` (variables that
|
|
22
|
+
* invert a RESULT), and separate for the same reason they are: this scrub withholds CREDENTIALS, and
|
|
23
|
+
* unlike those two it also applies to installs — a patched package.json's `postinstall` is code the
|
|
24
|
+
* builder wrote too.
|
|
25
|
+
*
|
|
26
|
+
* NOT A SANDBOX. The child still runs as the operator, with $HOME and the network: `~/.honerc` holds
|
|
27
|
+
* the same token and any test can read it. This closes the leak that was measured — a credential
|
|
28
|
+
* handed to generated code in its environment — not a patch written to steal one. HC-GF-270 keeps an
|
|
29
|
+
* outside party's patch from running unattended at all (cli/lib/story-provenance.js); OS isolation of
|
|
30
|
+
* the child remains a separate opt-in.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Hone's namespace: the CLI's and the server's variables, credentials among them. */
|
|
34
|
+
const HONE_PREFIX = 'HONE_';
|
|
35
|
+
|
|
36
|
+
/** Case-folded: Windows resolves `process.env.HONE_TOKEN` against a key spelled `Hone_Token`. */
|
|
37
|
+
function isHoneVar(key) {
|
|
38
|
+
return String(key).toUpperCase().startsWith(HONE_PREFIX);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {NodeJS.ProcessEnv} [base] Base env; defaults to process.env. Injectable for tests.
|
|
43
|
+
* @returns {NodeJS.ProcessEnv} a copy — the caller's environment is untouched
|
|
44
|
+
*/
|
|
45
|
+
function withoutHoneVars(base) {
|
|
46
|
+
const env = { ...(base || process.env) };
|
|
47
|
+
for (const key of Object.keys(env)) if (isHoneVar(key)) delete env[key];
|
|
48
|
+
return env;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { withoutHoneVars, isHoneVar, HONE_PREFIX };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* verdict-env.js — HC-GF-144. The environment for a subprocess whose VERDICT we read.
|
|
4
|
+
*
|
|
5
|
+
* `node --test` exports NODE_TEST_CONTEXT and NODE_TEST_WORKER_ID to every process it spawns, and
|
|
6
|
+
* a nested `node --test` that inherits them exits 0 EVEN WHEN TESTS FAIL (measured: exit 1 with a
|
|
7
|
+
* clean env, exit 0 with the variable). Anything whose exit code is believed must run without them.
|
|
8
|
+
*
|
|
9
|
+
* Sibling to `git-env.js`, and deliberately separate: that one removes the variables that select a
|
|
10
|
+
* REPOSITORY, this one removes the variables that invert a RESULT. Merging them would apply each
|
|
11
|
+
* scrub where it is not wanted.
|
|
12
|
+
*
|
|
13
|
+
* NOT for subprocesses that exist to USE the operator's toolchain — pip, npm and playwright
|
|
14
|
+
* installs need the real environment (proxy, registry, an active virtualenv), and scrubbing it
|
|
15
|
+
* there is the bug rather than the fix.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Variables a parent test runner exports that flip a child runner's exit code. */
|
|
19
|
+
const VERDICT_FLIPPING_VARS = ['NODE_TEST_CONTEXT', 'NODE_TEST_WORKER_ID'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {NodeJS.ProcessEnv} [base] Base env; defaults to process.env. Injectable for tests.
|
|
23
|
+
* @returns {NodeJS.ProcessEnv}
|
|
24
|
+
*/
|
|
25
|
+
function verdictEnv(base) {
|
|
26
|
+
const env = { ...(base || process.env) };
|
|
27
|
+
for (const key of VERDICT_FLIPPING_VARS) delete env[key];
|
|
28
|
+
return env;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { verdictEnv, VERDICT_FLIPPING_VARS };
|