From 7091f2c31322f2c74b04671ac68ba5a7f4955164 Mon Sep 17 00:00:00 2001 From: BillyOutlast <172061051+BillyOutlast@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:55:59 -0400 Subject: [PATCH] Release 1.1.4: human-only shell rule fix (#88), mention hint (#86) (#89) - Security (#88): the human-only shell rule finds the es verb past flags and `--`, as the CLI parser does; `es --cwd . ` no longer slips past it. - #86: refusals for text that only names a guarded verb point at passing it by file (gh --body-file, git commit -F); the quote-blind deny stays. - @babel/core 7.29.6 in packages/opencode (dev only; replaces #87). - Release 1.1.4: versions, CHANGELOG, README status. Closes the 1.1.x proof-of-work phase; the user waived the dogfood and the local real-model run. Fixes #88. Fixes #86. Closes #35. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 43 +++++++++++++++++ README.md | 12 +++-- bun.lock | 14 ++++-- packages/cli/package.json | 2 +- packages/cli/src/version.ts | 2 +- packages/core/package.json | 2 +- packages/core/src/trust/policy.ts | 79 ++++++++++++++++++++++++++----- packages/core/test/policy.test.ts | 45 ++++++++++++++++++ packages/opencode/package.json | 4 +- 9 files changed, 176 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c97c5d6..eaa6616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## 1.1.4 — 2026-10-08 + +A security fix for the human-only shell rule, plus the E2E feedback fixes +(#85). This closes the 1.1.x proof-of-work phase (#35); the user waived the +dogfood and the local real-model run. + +**Security.** +- **#88: a flag before the verb slipped past the human-only shell rule.** + The rule read only the word right after the es binary, but the CLI takes + `--flag`, `--flag value`, `--flag=value` and `--` anywhere. So + `es --cwd . approve spec`, `es -- approve spec`, + `es --run factory resume` and `es factory --cwd . resume` were + allowed for every agent, factory seats included. + - Approvals and keys stayed guarded by the masked key and the + terminal-only passphrase. Spend-bearing `audit` and `scout` runs and + `factory resume` were not. + - The rule now finds the verb the way the CLI parser does. A bare `--flag` + counts both as a boolean and as taking a value. + - Upgrade from any earlier version. + +**Fixes.** +- **#86: refusals for text that only names a guarded verb.** The rule stays + quote-blind, so a here-doc, a commit message or a `--body` argument that + mentions a human-only verb is still refused. The refusal now says how to + pass such text by file (`gh … --body-file`, `git commit -F`). +- **The grill skill's first turn asks and never records** (#85). The seat + had been writing the frontier before asking anything. + +**Evals and CI (#85).** +- The `programmer` case stages a BUILD run with a phase worktree, so it can + pass (#66). +- Step caps were re-budgeted from observed need, and the `ES_EVAL_MAX_STEPS` + ceiling went from 16 to 24 (#67). +- `e2e-feedback` fails only when a historically stable case fails + (`scripts/e2e-gate.ts`, #68). `audit-fires` is marked model-limited on the + free CI model. +- Grading reads completed tool outputs, except the raw fetch tools, whose + verbatim quoting is the skill under test. +- Kept workspaces mirror `.factory/` to `keep/factory/`, because the + artifact upload drops dot-dirs. + +**Dependencies.** `@babel/core` 7.29.6, dev only (GHSA-4x5r-pxfx-6jf8). + ## 1.1.3 — 2026-10-08 Live-run visibility and seat discipline, from the 1.1.2 dogfood (#63): a diff --git a/README.md b/README.md index 2a815db..e86f4b0 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ > phases under dual QA, and open a PR a human merges. Mechanical gates and a > human-only approval channel keep autonomous runs honest. -> **Status: 1.1.3.** Live-run visibility and seat discipline from the 1.1.2 -> dogfood (#63). Every status view leads with whether the run is working, -> waiting, stuck or done: `es status`, `es watch`, `es runs`, the factory -> dashboard and a TUI footer indicator. Factory seats run in the foreground, -> with one writer per research file. 1.1.2 added `audit.phase`, pipeline A and +> **Status: 1.1.4.** A security fix: a flag before the verb +> (`es --cwd . `) no longer slips past the human-only shell rule +> (#88). 1.1.3 added live-run visibility and seat discipline. Every status +> view leads with whether the run is working, waiting, stuck or done: +> `es status`, `es watch`, `es runs`, the factory dashboard and a TUI footer +> indicator. Factory seats run in the foreground, with one writer per +> research file. 1.1.2 added `audit.phase`, pipeline A and > signed run state; 1.1.1 was the security release (bubblewrap for every agent > shell, the passphrase-sealed Ed25519 human key, terminal-only approvals). > Install from npm (`@heretek-ai/epistemic-swarm`, `@heretek-ai/es-core`, diff --git a/bun.lock b/bun.lock index c99cb19..a1259b7 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "packages/cli": { "name": "@heretek-ai/es-cli", - "version": "1.1.3", + "version": "1.1.4", "bin": { "es-factory": "./dist/bin.js", "iumbtems": "./dist/bin.js", @@ -25,7 +25,7 @@ }, "packages/core": { "name": "@heretek-ai/es-core", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "vscode-jsonrpc": "9.0.3", "vscode-languageserver-protocol": "3.18.4", @@ -35,13 +35,13 @@ }, "packages/opencode": { "name": "@heretek-ai/epistemic-swarm", - "version": "1.1.3", + "version": "1.1.4", "dependencies": { "@heretek-ai/es-core": "workspace:*", "@opencode/plugin": "2.0.24", }, "devDependencies": { - "@babel/core": "7.28.0", + "@babel/core": "7.29.6", "@babel/preset-typescript": "7.27.1", "@heretek-ai/es-testkit": "workspace:*", "@opencode/sdk": "2.0.24", @@ -140,7 +140,7 @@ "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + "@babel/core": ["@babel/core@7.29.6", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.6", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.29.2", "@babel/parser": "^7.29.3", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA=="], "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], @@ -260,6 +260,8 @@ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], @@ -1136,6 +1138,8 @@ "@opentui/core/web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], + "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index 46d6feb..754195d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@heretek-ai/es-cli", - "version": "1.1.3", + "version": "1.1.4", "description": "Epistemic Swarm CLI: factory control, human-only checkpoints, gates, headless runs and a coarse MCP server.", "type": "module", "license": "Apache-2.0", diff --git a/packages/cli/src/version.ts b/packages/cli/src/version.ts index e8a85ba..b81f520 100644 --- a/packages/cli/src/version.ts +++ b/packages/cli/src/version.ts @@ -1,2 +1,2 @@ /** The `es` CLI version. Single source: kept equal to the package version by test/cli.test.ts. */ -export const VERSION = "1.1.3" +export const VERSION = "1.1.4" diff --git a/packages/core/package.json b/packages/core/package.json index 1309830..471e208 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@heretek-ai/es-core", - "version": "1.1.3", + "version": "1.1.4", "description": "Epistemic Swarm core: factory state machine, mechanical gates, audit log, trust, research, brainstorm, harvest, LSP and hook/agent compilers.", "type": "module", "license": "Apache-2.0", diff --git a/packages/core/src/trust/policy.ts b/packages/core/src/trust/policy.ts index b81650d..6b3ac10 100644 --- a/packages/core/src/trust/policy.ts +++ b/packages/core/src/trust/policy.ts @@ -157,18 +157,77 @@ function isPlainHelp(command: string): boolean { return help > 0 && (end === -1 || help < end) } -/** Whether the command invokes a human-only es verb, however it is quoted or wrapped. */ +/** A word position, and whether a `--` before it made every word positional. */ +interface Cursor { + readonly at: number + readonly literal: boolean +} + +/** + * Where the CLI parser's next positional could be, scanning from `from` (an + * index past the end means there is none). The parser takes flags anywhere + * (#88): `--` makes the rest positional, and `--flag value` consumes the + * value unless the flag is boolean. The boolean list is the CLI's, so a bare + * `--flag` counts both ways; `--flag=value` is two words here (`words` splits + * at `=`) and reads the same. + */ +function nextPositionals(tokens: readonly string[], from: Cursor): Cursor[] { + const found: Cursor[] = [] + const seen = new Set() + const stack = [from] + while (stack.length > 0) { + const cursor = stack.pop()! + const key = `${cursor.at}:${cursor.literal}` + if (seen.has(key)) continue + seen.add(key) + const token = tokens[cursor.at] + if (token === undefined || cursor.literal || !token.startsWith("--")) found.push(cursor) + else if (token === "--") stack.push({ at: cursor.at + 1, literal: true }) + else stack.push({ at: cursor.at + 1, literal: false }, { at: cursor.at + 2, literal: false }) + } + return found +} + +/** Whether the es binary at `tokens[binary]` can run a human-only verb. */ +function humanVerbAfter(tokens: readonly string[], binary: number): boolean { + return nextPositionals(tokens, { at: binary + 1, literal: false }).some((verb) => { + const rule = HUMAN_VERBS.find(([name]) => name === tokens[verb.at]) + if (rule === undefined) return false + const [, sub] = rule + if (sub === undefined) return true + return nextPositionals(tokens, { at: verb.at + 1, literal: verb.literal }).some((next) => sub(tokens[next.at])) + }) +} + +/** + * Whether the command invokes a human-only es verb, however it is quoted or + * wrapped. Deliberately quote-blind, so text that only names a verb (a + * here-doc, a `-m` or `--body` argument) reads as a call too (#86). That false + * positive is the price of catching `sh -c`, `xargs` and interpreter wrappers; + * the refusal tells the agent to pass such text by file instead. + */ export function invokesHumanOnly(command: string): boolean { if (isPlainHelp(command)) return false const tokens = words(unquoteShell(command)) - return tokens.some((token, index) => { - if (!ES_BINARY.test(token)) return false - const verb = tokens[index + 1] - const rule = HUMAN_VERBS.find(([name]) => name === verb) - return rule !== undefined && (rule[1] === undefined || rule[1](tokens[index + 2])) - }) + return tokens.some((token, index) => ES_BINARY.test(token) && humanVerbAfter(tokens, index)) } +/** + * Whether a human-only match is a plain call: no here-doc, and an es binary + * heading its segment. Anything else may be text that only names the verb. + */ +const calledAtHead = (command: string): boolean => + !command.includes("<<") && + segments(unquoteShell(command)).some((segment) => { + const tokens = words(segment) + return ES_BINARY.test(tokens[0] ?? "") && humanVerbAfter(tokens, 0) + }) + +const HUMAN_ONLY = + "That command is human-only (approvals, trust, waivers, resume, rebaseline, keys, config set, retractions, exports, audit and scout runs); agents cannot run it." +const MENTION_HINT = + " If it only names the verb in text (a here-doc, a commit message, a `--body` argument), the rule still reads a call: write the text to a file with the edit tool and pass the file (`gh … --body-file`, `git commit -F`)." + const CONTROL_MENTION = /\.factory\/(gates\.json|config\.json|frontier\.json|waivers|approvals|runtime|STOP|git-hooks|claims\b|audits\b|research\/(sources\b|(coverage|dossier|brief\.pcrb)\.json)|(brainstorm|harvest|design|scout)\/[^\s'"]*\.json)|\.git\/(config|hooks)|\.opencode\/(hooks\.json|plugins|opencode\.jsonc?)|\.claude\/settings|opencode\.jsonc?\b/i /** Any mention of the factory dir (a `cd .factory` reaches control files without naming them). */ @@ -249,11 +308,7 @@ export function evaluateShell( const spec = agentSpec(agentId) const plain = unquoteShell(command) if (invokesHumanOnly(command)) - return { - effect: "deny", - reason: - "That command is human-only (approvals, trust, waivers, resume, rebaseline, keys, config set, retractions, exports, audit and scout runs); agents cannot run it.", - } + return { effect: "deny", reason: calledAtHead(command) ? HUMAN_ONLY : HUMAN_ONLY + MENTION_HINT } const privateDir = canonicalPath(context.stateDir ?? stateDir()) if (plain.includes(privateDir) || /epistemic-swarm\/(key|trust|engine)|state\/epistemic-swarm/i.test(plain)) return { effect: "deny", reason: "Agents may not access Epistemic Swarm's private state dir." } diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts index ce578e2..628d636 100644 --- a/packages/core/test/policy.test.ts +++ b/packages/core/test/policy.test.ts @@ -232,6 +232,51 @@ describe("shell policy", () => { expect([command, shell("build", command).effect]).toEqual([command, "deny"]) }) + test("a flag or `--` before the verb still reads as that verb, as the CLI parser does (#88)", () => { + for (const command of [ + "es --cwd . approve spec", + "es --cwd=. approve spec", + "es -- approve spec", + "es --json approve spec", + "es --cwd . reseal --sign", + "es --cwd . --json key seal", + "es --run run-1 factory resume", + "es factory --cwd . resume", + "es gates --cwd=. install-git", + "es --cwd . audit src --max-usd 5", + "es --cwd . config set models.deep x/y", + "npx @heretek-ai/es-cli --cwd . approve spec", + ]) + for (const agent of ["build", "es-programmer"]) + expect([agent, command, shell(agent, command).effect]).toEqual([agent, command, "deny"]) + // Reads keep working with flags around them. + for (const command of [ + "es --cwd . status", + "es --run run-1 status", + "es --cwd . config show", + "es --cwd . audit verify", + "es --cwd . factory status", + ]) + expect([command, shell("build", command).effect]).toEqual([command, "allow"]) + }) + + test("a verb only mentioned in text is still denied, and the refusal points at passing the text by file (#86)", () => { + const hint = /--body-file/ + const reason = (decision: ReturnType) => (decision.effect === "deny" ? decision.reason : "") + for (const command of [ + "gh issue comment 1 --body \"$(cat <<'EOF'\nrun es approve spec in a terminal\nEOF\n)\"", + "gh issue comment 1 --body-file - <<'EOF'\nes approve spec\nEOF", + 'git commit -m "docs: es reseal --sign re-signs the state"', + ]) { + const decision = shell("build", command) + expect([command, decision.effect]).toEqual([command, "deny"]) + expect([command, reason(decision)]).toEqual([command, expect.stringMatching(hint)]) + } + // A plain call gets the plain refusal. + for (const command of ["es approve spec", "cd x && es --cwd . reseal --sign"]) + expect([command, reason(shell("build", command))]).toEqual([command, expect.not.stringMatching(hint)]) + }) + test("seats may not reach the evidence cache from the shell; others may read but not mutate it", () => { expect(shell("es-research-alpha", "cat .factory/research/sources/index.json").effect).toBe("deny") expect(shell("build", "cat .factory/research/sources/index.json").effect).toBe("allow") diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 01bc232..e044c55 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@heretek-ai/epistemic-swarm", - "version": "1.1.3", + "version": "1.1.4", "description": "Epistemic Swarm: an AI build factory for OpenCode v2 \u2014 grill, research, spec, build, QA and release, gated mechanically.", "type": "module", "license": "Apache-2.0", @@ -45,7 +45,7 @@ "@opencode/plugin": "2.0.24" }, "devDependencies": { - "@babel/core": "7.28.0", + "@babel/core": "7.29.6", "@babel/preset-typescript": "7.27.1", "@heretek-ai/es-testkit": "workspace:*", "@opencode/sdk": "2.0.24",