Fix: Setup survives a refused launcher (#750)

Preserve Setup context and reference loading after launcher refusal, disclose the failure before editing, and limit Claude skill-directory substitution to SKILL.md. Add plugin-path and denied-launcher behavior regressions. Addresses part of #744 without closing its remaining scope.

AI assistance: Cursor on the original contribution; Codex on maintainer-directed follow-up fixes and validation.
This commit is contained in:
Abdul Wahab
2026-09-06 13:07:29 -07:00
committed by GitHub
parent 720628f4ea
commit 3e653377de
7 changed files with 195 additions and 38 deletions
+32 -17
View File
@@ -13,10 +13,12 @@ import { generateYamlFrontmatter, parseFrontmatter } from './utils.js';
* project's (possibly older) skill copy.
*
* No literal path survives installation (the plugin cache location varies
* per machine and per plugin version), so skill and reference markdown
* uses the `<skill-base-dir>` form SKILL.md's Setup step 1 already leads
* with: the runtime shows the skill's loaded base directory when it loads
* the skill, and scripts resolve against that. Agent files cannot use the
* per machine and per plugin version), so the plugin SKILL.md uses
* ${CLAUDE_SKILL_DIR} (issue #744): the directory containing SKILL.md,
* substituted by Claude Code at load time. Source uses `<skill-base-dir>`;
* the rewrite maps it to the host variable only in SKILL.md. References
* arrive through ordinary file reads and retain the explicit placeholder;
* CLAUDE_SKILL_DIR is not an exported shell variable. Agent files cannot use either
* token (a spawned agent never loads SKILL.md) and get the
* ${CLAUDE_PLUGIN_ROOT} variable instead; see PLUGIN_AGENT_SCRIPTS_PATH.
*/
@@ -25,7 +27,7 @@ import { generateYamlFrontmatter, parseFrontmatter } from './utils.js';
// Claude Code transformer's configDir (.claude) + skill name (impeccable).
export const CLAUDE_PROJECT_SCRIPTS_PATH = '.claude/skills/impeccable/scripts';
export const PLUGIN_SCRIPTS_PATH = '<skill-base-dir>/scripts';
export const PLUGIN_SCRIPTS_PATH = '${CLAUDE_SKILL_DIR}/scripts';
// Claude Code requires user consent to activate a skill whose frontmatter
// declares allowed-tools; non-interactive hosts (`claude -p`) cannot provide
@@ -43,13 +45,14 @@ function stripAllowedToolsFrontmatter(content) {
// Setup step 1's second sentence names the project path as the fallback
// when the runtime reports no base directory. A plugin install has no
// working project fallback (that path is the bug this rewrite exists to
// fix), and every instruction in the plugin copy already carries the
// token, so the sentence loses its fallback clause.
// fix). SKILL.md receives host substitution; references need the directory
// resolved by the assistant, so spell out that boundary in the entrypoint.
const SETUP_FALLBACK_TEXT =
'That base directory resolves every `.claude/skills/impeccable/scripts/impeccable <verb>` command in this skill and its references, ' +
'and `.claude/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory.';
const SETUP_PLUGIN_TEXT =
'Every `"<skill-base-dir>/scripts/impeccable" <verb>` command in this skill and its references resolves against that base directory.';
'Every `"${CLAUDE_SKILL_DIR}/scripts/impeccable" <verb>` command in this skill and its references resolves against that base directory. ' +
'In reference files, replace the skill-base-dir placeholder with this directory before running commands; it is not a shell variable.';
// Agent files are subagent system prompts: a spawned agent never loads
// SKILL.md, so Setup's <skill-base-dir> token is undefined in the one
@@ -76,21 +79,26 @@ export const AGENT_EMBED_FALLBACK =
* Rewrite one markdown file's content for the plugin subtree. Pure, so the
* unit suite can pin every rewrite without a build.
*/
export function rewritePluginMarkdown(content) {
export function rewritePluginMarkdown(content, { isSkillEntrypoint = true } = {}) {
const baseDir = isSkillEntrypoint ? '${CLAUDE_SKILL_DIR}' : '<skill-base-dir>';
return stripAllowedToolsFrontmatter(content)
.replaceAll(PROJECT_ALLOWED_TOOLS_LINE, '')
.replaceAll(SETUP_FALLBACK_TEXT, SETUP_PLUGIN_TEXT)
.replaceAll(CLAUDE_PROJECT_SCRIPTS_PATH, PLUGIN_SCRIPTS_PATH)
// <skill-base-dir> expands to a real path at run time, and an unquoted
.replaceAll(CLAUDE_PROJECT_SCRIPTS_PATH, isSkillEntrypoint ? PLUGIN_SCRIPTS_PATH : `${baseDir}/scripts`)
.replaceAll('<skill-base-dir>', baseDir)
// ${CLAUDE_SKILL_DIR} expands to a real path at load time, and an unquoted
// path with spaces splits before node sees it. Quote every command's
// script argument, including the token-form commands SKILL.src.md
// script argument, including the host-variable commands SKILL.src.md
// carries natively (Setup step 1). Runs after the path replacement so
// one pattern covers both origins; already-quoted forms don't match.
.replace(/node <skill-base-dir>\/scripts\/([^\s`"]+)/g, 'node "<skill-base-dir>/scripts/$1"')
// The engine launcher is the command itself now (`<skill-base-dir>/scripts/impeccable <verb>`,
.replace(/node (\$\{CLAUDE_SKILL_DIR\}|<skill-base-dir>)\/scripts\/([^\s`"]+)/g, 'node "$1/scripts/$2"')
// The engine launcher is the command itself (`${CLAUDE_SKILL_DIR}/scripts/impeccable <verb>`,
// or `impeccable.cmd` on a Windows shell without sh), so the launcher path
// is what gets quoted; the verb and its arguments follow unquoted.
.replace(/(?<!["\w/])<skill-base-dir>\/scripts\/impeccable(\.cmd)?(?=[\s`])/g, '"<skill-base-dir>/scripts/impeccable$1"');
.replace(
/(?<!["\w/])(\$\{CLAUDE_SKILL_DIR\}|<skill-base-dir>)\/scripts\/impeccable(\.cmd)?(?=[\s`])/g,
'"$1/scripts/impeccable$2"',
);
}
/**
@@ -151,11 +159,18 @@ export function verifyPluginSkillRewrite(skillMdPath) {
const content = fs.readFileSync(skillMdPath, 'utf-8');
if (!content.includes(SETUP_PLUGIN_TEXT)) {
throw new Error(
`Plugin rewrite drift: ${skillMdPath} is missing the <skill-base-dir> resolution sentence. ` +
`Plugin rewrite drift: ${skillMdPath} is missing the \${CLAUDE_SKILL_DIR} resolution sentence. ` +
"SKILL.src.md's Setup step 1 fallback sentence no longer matches the replacement in " +
'scripts/lib/plugin-paths.js (issue #523); update SETUP_FALLBACK_TEXT to the new wording.',
);
}
if (content.includes('<skill-base-dir>')) {
throw new Error(
`Plugin rewrite drift: ${skillMdPath} still contains the <skill-base-dir> token. ` +
'Plugin skill markdown must use ${CLAUDE_SKILL_DIR} (issue #744); check replaceAll in ' +
'scripts/lib/plugin-paths.js.',
);
}
if (parseFrontmatter(content).frontmatter['allowed-tools'] !== undefined) {
throw new Error(
`Plugin rewrite drift: ${skillMdPath} still declares allowed-tools in frontmatter. ` +
@@ -195,7 +210,7 @@ export function rewritePluginMarkdownTree(dir, rewrite = rewritePluginMarkdown)
rewritePluginMarkdownTree(entryPath, rewrite);
} else if (entry.name.endsWith('.md')) {
const original = fs.readFileSync(entryPath, 'utf-8');
const rewritten = rewrite(original);
const rewritten = rewrite(original, { isSkillEntrypoint: entry.name === 'SKILL.md' });
if (rewritten !== original) fs.writeFileSync(entryPath, rewritten);
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ Core principles:
## Setup
1. Run `<skill-base-dir>/scripts/impeccable context` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `{{scripts_path}}/impeccable <verb>` command in this skill and its references, and `{{scripts_path}}` is the fallback only when the runtime reports no base directory. On a Windows shell without `sh`, call `{{scripts_path}}/impeccable.cmd` instead. The launcher runs a self-contained binary that ships next to it or is downloaded once on first run; no Node or other runtime is required. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. <!-- rule:skill-setup-context -->
1. Run `<skill-base-dir>/scripts/impeccable context` once per session, where `<skill-base-dir>` is the directory that contains this SKILL.md (the skill folder, not a plugin root two levels above it); keep cwd at the user's project. That base directory resolves every `{{scripts_path}}/impeccable <verb>` command in this skill and its references, and `{{scripts_path}}` is the fallback only when the runtime reports no base directory. On a Windows shell without `sh`, call `{{scripts_path}}/impeccable.cmd` instead. The launcher runs a self-contained binary that ships next to it or is downloaded once on first run; no Node or other runtime is required. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it. If the launcher is refused, missing, or fails, tell the user before editing that context loading did not run. Read existing **PRODUCT.md** and **DESIGN.md** without inventing missing context, then continue with steps 23. <!-- rule:skill-setup-context -->
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures. <!-- rule:skill-setup-command-ref --> <!-- rule:skill-setup-read-project -->
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work. <!-- rule:skill-craft-floor-load -->
+53 -14
View File
@@ -6,7 +6,8 @@
* `.claude/skills/impeccable/scripts`. Run from the plugin cache, that path
* points into the user's project: a plugin-only user gets MODULE_NOT_FOUND,
* and a dual-install user silently runs the project's older skill copy. The
* rewrite swaps every markdown instruction to the `<skill-base-dir>` form
* rewrite uses `${CLAUDE_SKILL_DIR}` only in SKILL.md; raw references keep
* the explicit `<skill-base-dir>` placeholder. It removes allowed-tools
* and drops the node pre-approval: no frontmatter rule can bind approval to
* the loaded plugin root, and an unbound wildcard would auto-approve any
* same-shaped path anywhere on disk.
@@ -15,6 +16,7 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'node:child_process';
import { parseFrontmatter } from '../scripts/lib/utils.js';
import {
rewritePluginMarkdown,
@@ -27,10 +29,10 @@ import {
} from '../scripts/lib/plugin-paths.js';
describe('rewritePluginMarkdown', () => {
test('rewrites a script instruction to the quoted skill-base-dir form', () => {
test('rewrites a script instruction to the quoted CLAUDE_SKILL_DIR form', () => {
const input = 'Run `node .claude/skills/impeccable/scripts/context.mjs` once per session.';
expect(rewritePluginMarkdown(input)).toBe(
'Run `node "<skill-base-dir>/scripts/context.mjs"` once per session.',
'Run `node "${CLAUDE_SKILL_DIR}/scripts/context.mjs"` once per session.',
);
});
@@ -41,8 +43,8 @@ describe('rewritePluginMarkdown', () => {
].join('\n');
const output = rewritePluginMarkdown(input);
expect(output).not.toContain(CLAUDE_PROJECT_SCRIPTS_PATH);
expect(output).toContain('node "<skill-base-dir>/scripts/live.mjs"');
expect(output).toContain('node "<skill-base-dir>/scripts/live-poll.mjs" --reply EVENT_ID done');
expect(output).toContain('node "${CLAUDE_SKILL_DIR}/scripts/live.mjs"');
expect(output).toContain('node "${CLAUDE_SKILL_DIR}/scripts/live-poll.mjs" --reply EVENT_ID done');
});
test('quotes the engine launcher path and leaves the verb outside the quotes', () => {
@@ -50,9 +52,9 @@ describe('rewritePluginMarkdown', () => {
'Run `<skill-base-dir>/scripts/impeccable context` once, then `<skill-base-dir>/scripts/impeccable.cmd doctor --json`; ' +
'already quoted: `"<skill-base-dir>/scripts/impeccable" hooks on`.',
);
expect(output).toContain('`"<skill-base-dir>/scripts/impeccable" context`');
expect(output).toContain('`"<skill-base-dir>/scripts/impeccable.cmd" doctor --json`');
expect(output).not.toContain('""<skill-base-dir>');
expect(output).toContain('`"${CLAUDE_SKILL_DIR}/scripts/impeccable" context`');
expect(output).toContain('`"${CLAUDE_SKILL_DIR}/scripts/impeccable.cmd" doctor --json`');
expect(output).not.toContain('""${CLAUDE_SKILL_DIR}');
});
test('quotes commands already in the skill-base-dir form without double-quoting', () => {
@@ -62,8 +64,8 @@ describe('rewritePluginMarkdown', () => {
'Run `node <skill-base-dir>/scripts/context.mjs` once per session. ' +
'Already quoted: `node "<skill-base-dir>/scripts/detect.mjs"`.';
expect(rewritePluginMarkdown(input)).toBe(
'Run `node "<skill-base-dir>/scripts/context.mjs"` once per session. ' +
'Already quoted: `node "<skill-base-dir>/scripts/detect.mjs"`.',
'Run `node "${CLAUDE_SKILL_DIR}/scripts/context.mjs"` once per session. ' +
'Already quoted: `node "${CLAUDE_SKILL_DIR}/scripts/detect.mjs"`.',
);
});
@@ -96,7 +98,7 @@ describe('rewritePluginMarkdown', () => {
'reports no base directory. Pass a named source file or route as `--target <path>`.';
const output = rewritePluginMarkdown(input);
expect(output).toContain(
'Every `"<skill-base-dir>/scripts/impeccable" <verb>` command in this skill and its references resolves against that base directory.',
'Every `"${CLAUDE_SKILL_DIR}/scripts/impeccable" <verb>` command in this skill and its references resolves against that base directory.',
);
// The naive rewrite would keep the fallback clause and name the token as
// its own fallback for when there is no base directory to resolve it.
@@ -213,7 +215,7 @@ describe('rewritePluginMarkdownTree', () => {
rewritePluginMarkdownTree(root);
expect(fs.readFileSync(path.join(root, 'SKILL.md'), 'utf-8')).toBe(
'Run `node "<skill-base-dir>/scripts/context.mjs"`.',
'Run `node "${CLAUDE_SKILL_DIR}/scripts/context.mjs"`.',
);
expect(fs.readFileSync(path.join(root, 'reference/live.md'), 'utf-8')).toBe(
'node "<skill-base-dir>/scripts/live.mjs"',
@@ -223,6 +225,27 @@ describe('rewritePluginMarkdownTree', () => {
);
});
test('raw references retain an explicit base-directory placeholder, including Windows commands', () => {
const input = 'Run `.claude/skills/impeccable/scripts/impeccable context` or `<skill-base-dir>/scripts/impeccable.cmd context`.';
const output = rewritePluginMarkdown(input, { isSkillEntrypoint: false });
expect(output).toBe('Run `"<skill-base-dir>/scripts/impeccable" context` or `"<skill-base-dir>/scripts/impeccable.cmd" context`.');
expect(output).not.toContain('${CLAUDE_SKILL_DIR}');
expect(rewritePluginMarkdown(output, { isSkillEntrypoint: false })).toBe(output);
});
test('entrypoint and explicitly resolved reference commands work from a cache path with spaces', () => {
const skillDir = path.join(root, 'plugin cache', 'skills', 'impeccable');
fs.mkdirSync(path.join(skillDir, 'scripts'), { recursive: true });
fs.writeFileSync(path.join(skillDir, 'scripts/probe.mjs'), 'console.log(process.argv[2]);');
const env = { ...process.env };
delete env.CLAUDE_SKILL_DIR;
for (const isSkillEntrypoint of [true, false]) {
const command = rewritePluginMarkdown('node .claude/skills/impeccable/scripts/probe.mjs resolved', { isSkillEntrypoint })
.replaceAll(isSkillEntrypoint ? '${CLAUDE_SKILL_DIR}' : '<skill-base-dir>', skillDir);
expect(execFileSync('sh', ['-c', command], { env, encoding: 'utf8' }).trim()).toBe('resolved');
}
});
test('applies the agent rewrite when passed for an agents tree', () => {
const agentsDir = path.join(root, 'agents');
fs.mkdirSync(agentsDir, { recursive: true });
@@ -284,7 +307,7 @@ describe('verifyPluginSkillRewrite', () => {
test('fails the build when a launcher pre-approval survives the removal', () => {
const p = writeSkill(
rewritePluginMarkdown(goodSkill) + '\n - Bash(<skill-base-dir>/scripts/impeccable.cmd *)\n',
rewritePluginMarkdown(goodSkill) + '\n - Bash(${CLAUDE_SKILL_DIR}/scripts/impeccable.cmd *)\n',
);
expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher/);
});
@@ -307,7 +330,7 @@ describe('verifyPluginSkillRewrite', () => {
// A copy that still carries a node pre-approval must fail the same way as
// a surviving launcher line, even outside an allowed-tools block.
const p = writeSkill(
rewritePluginMarkdown(goodSkill) + '\n - Bash(node <skill-base-dir>/scripts/*)\n',
rewritePluginMarkdown(goodSkill) + '\n - Bash(node ${CLAUDE_SKILL_DIR}/scripts/*)\n',
);
expect(() => verifyPluginSkillRewrite(p)).toThrow(/pre-approves an engine launcher or node script path/);
});
@@ -321,6 +344,11 @@ describe('verifyPluginSkillRewrite', () => {
);
expect(() => verifyPluginSkillRewrite(p)).toThrow(/still contains the project-relative scripts path/);
});
test('fails the build when the skill-base-dir token survives the rewrite', () => {
const p = writeSkill(rewritePluginMarkdown(goodSkill).replace('${CLAUDE_SKILL_DIR}', '<skill-base-dir>'));
expect(() => verifyPluginSkillRewrite(p)).toThrow(/still contains the <skill-base-dir> token/);
});
});
describe('SKILL.src.md frontmatter', () => {
@@ -333,3 +361,14 @@ describe('SKILL.src.md frontmatter', () => {
expect(frontmatter['allowed-tools']).toBeDefined();
});
});
describe('SKILL.src.md Setup step 1 authoring contract (issue #744)', () => {
test('disambiguates the skill-base-dir token', () => {
const setup = fs.readFileSync(
path.join(import.meta.dirname, '../skill/SKILL.src.md'),
'utf-8',
).replace(/\r\n?/g, '\n');
const step1 = setup.match(/^1\. .+$/m)?.[0] ?? '';
expect(step1).toMatch(/skill folder, not a plugin root/);
});
});
+26
View File
@@ -4,6 +4,32 @@ import fs from 'node:fs';
import path from 'node:path';
import { prepareWorkspace, cleanupWorkspace, makeTools } from './skill-behavior/harness.mjs';
it('denied-launcher tools reject every shell attempt without executing or modifying the skill', async () => {
const workspace = prepareWorkspace({ files: { 'index.html': 'before' } });
try {
const { tools, trace } = makeTools(workspace, {}, {}, { denyBash: true });
for (const command of [
'.claude/skills/impeccable/scripts/impeccable context',
'.claude/skills/impeccable/scripts/impeccable context; echo bad > index.html',
'echo bad > index.html',
]) {
assert.match(await tools.bash.execute({ command }), /permission denied/i);
}
assert.equal(fs.readFileSync(path.join(workspace, 'index.html'), 'utf8'), 'before');
assert.ok(trace.toolCalls.every((call) => call.denied && call.mutatedPaths.length === 0));
const skillPath = '.claude/skills/impeccable/reference/polish.md';
const before = await tools.read.execute({ path: skillPath });
assert.match(await tools.write.execute({ path: skillPath, contents: 'bad' }), /^Error:/);
assert.equal(await tools.read.execute({ path: skillPath }), before);
await tools.read.execute({ path: 'missing.md' });
assert.deepEqual(trace.toolCalls.filter((call) => call.name === 'read').map((call) => call.succeeded), [true, true, false]);
await tools.write.execute({ path: 'index.html', contents: 'after' });
assert.deepEqual(trace.toolCalls.flatMap((call) => call.mutatedPaths), ['index.html']);
} finally {
cleanupWorkspace(workspace);
}
});
it('context-only routing tools reject shell searches and compound commands before execution', async () => {
const workspace = prepareWorkspace({ files: { 'index.html': 'before' } });
try {
+22
View File
@@ -75,6 +75,28 @@ The trace is the source of truth, not the model's free-form reply.
| 16 | existing surface, with and without PRODUCT.md; asks where to start | loads `routing.md`, delivers advice, and does not edit project files, start an interview, archive a critique, or run menu scans |
| 17 | existing surface; asks whether critique is required before polish | loads `routing.md` and both command references, then delivers advice without executing the playbooks |
| 18 | existing surface; explicitly requests polish followed by a next-command recommendation | loads `polish.md` rather than substituting workflow advice for the requested work |
| 19 | tiny spacing edit with PRODUCT.md + DESIGN.md; Bash denied, plus a real-loader success control | actually reads playbook and craft floor before editing; denial also requires direct context-file reads and a user-visible warning before the edit |
## Setup launcher-failure branch (2026-09-06, PR #750)
Scenario 19 injects a host permission error before any shell command executes,
including retries and compound commands. File tools remain available; the
staged skill is read-only. Assertions require successful reads, an actual UI
edit, unchanged context files, and disclosure before the first write in the
assistant message sequence. Failed read attempts and shell commands merely
mentioning a reference do not count as loading it. The success control allows
only the real context-loader command through Bash and requires exit 0.
The suite measures continuation after a tool refusal, not Claude's skill
activation or plugin substitution. Those are separate loader/path checks.
It also does not establish behavior for every missing-binary or runtime error.
Focused baseline (2026-09-06): both scenario 19 cases passed on
`claude-sonnet-5`, one run each. The original wording also continued after
denial in the comparison run, but disclosed the failure only in its final
summary. This does not reproduce the reporter's complete reference-loading
failure or establish a multi-provider pass. An earlier scenario 6 result used
attempt-based reference assertions and is not counted as a success control.
## Workflow-advice baseline (2026-09-05, PR #737)
+18 -6
View File
@@ -219,7 +219,7 @@ function defaultSimulatedAnswer(question) {
return 'Use the brief, preserve real operational content, and make the primary decision obvious.';
}
export function makeTools(workspace, extraEnv = {}, simulatedUser = {}, { contextOnlyBash = false } = {}) {
export function makeTools(workspace, extraEnv = {}, simulatedUser = {}, { contextOnlyBash = false, denyBash = false } = {}) {
const trace = {
toolCalls: [],
bashCommands: [],
@@ -250,6 +250,14 @@ export function makeTools(workspace, extraEnv = {}, simulatedUser = {}, { contex
}),
execute: async ({ command }) => {
const call = record('bash', { command });
// Simulate a host refusal, not a process failure. Nothing reaches a
// shell, including retries, alternate launchers, and compound commands.
if (denyBash) {
call.denied = true;
const out = 'Error: Bash permission denied by the host. This command was not executed.';
trace.bashOutputs.push(out);
return out;
}
// Routing tests need the real context loader, not a general-purpose
// shell on the host. Reject before execution (still record attempts).
if (contextOnlyBash && command.trim() !== '.claude/skills/impeccable/scripts/impeccable context') {
@@ -273,13 +281,16 @@ export function makeTools(workspace, extraEnv = {}, simulatedUser = {}, { contex
path: z.string().describe('Workspace-relative file path.'),
}),
execute: async ({ path: p }) => {
record('read', { path: p });
const call = record('read', { path: p });
call.succeeded = false;
const resolved = safeResolve(workspace, p);
if (typeof resolved !== 'string') return `Error: ${resolved.error}`;
if (!fs.existsSync(resolved)) return `File not found: ${p}`;
const stat = fs.statSync(resolved);
if (stat.isDirectory()) return `Path is a directory: ${p}. Use list instead.`;
return fs.readFileSync(resolved, 'utf8');
const contents = fs.readFileSync(resolved, 'utf8');
call.succeeded = true;
return contents;
},
}),
write: tool({
@@ -292,7 +303,7 @@ export function makeTools(workspace, extraEnv = {}, simulatedUser = {}, { contex
const call = record('write', { path: p, contents });
const resolved = safeResolve(workspace, p);
if (typeof resolved !== 'string') return `Error: ${resolved.error}`;
if (contextOnlyBash && path.relative(workspace, resolved).split(path.sep)[0] === '.claude') {
if ((contextOnlyBash || denyBash) && path.relative(workspace, resolved).split(path.sep)[0] === '.claude') {
return 'Error: the staged skill is read-only; edits must target project files.';
}
fs.mkdirSync(path.dirname(resolved), { recursive: true });
@@ -370,8 +381,8 @@ export function makeTools(workspace, extraEnv = {}, simulatedUser = {}, { contex
// run is never killed. The timer is unref'd (it must not keep the loop alive
// after a healthy turn) and cleared on completion.
const TURN_TIMEOUT_MS = Number(process.env.IMPECCABLE_SKILL_BEHAVIOR_TURN_TIMEOUT_MS) || 840_000;
export async function runTurn({ workspace, model, userPrompt, priorMessages = [], maxSteps = 8, env = {}, simulatedUser = {}, timeoutMs = TURN_TIMEOUT_MS, contextOnlyBash = false }) {
const { tools, trace } = makeTools(workspace, env, simulatedUser, { contextOnlyBash });
export async function runTurn({ workspace, model, userPrompt, priorMessages = [], maxSteps = 8, env = {}, simulatedUser = {}, timeoutMs = TURN_TIMEOUT_MS, contextOnlyBash = false, denyBash = false }) {
const { tools, trace } = makeTools(workspace, env, simulatedUser, { contextOnlyBash, denyBash });
const messages = [
...priorMessages,
{ role: 'user', content: userPrompt },
@@ -409,6 +420,7 @@ export async function runTurn({ workspace, model, userPrompt, priorMessages = []
return {
trace,
text: result.text ?? '',
stepTexts: result.steps.map((step) => step.text ?? ''),
finishReason: result.finishReason,
usage: result.usage,
responseMessages,
+43
View File
@@ -652,6 +652,49 @@ for (const modelId of resolveModelList()) {
}
});
for (const denyBash of [true, false]) {
it(`scenario 19: ${denyBash ? 'denied launcher' : 'successful launcher control'} loads context and references before editing`, async () => {
const workspace = prepareWorkspace({ files: {
'PRODUCT.md': PRODUCT_MD_SAMPLE,
'DESIGN.md': DESIGN_MD_SAMPLE,
'index.html': '<!doctype html><html lang="en"><head><title>Fieldnotes</title><style>body{font:16px system-ui;margin:32px}button{padding:2px 4px}</style></head><body><main><h1>Fieldnotes</h1><p>A calmer place for your notes.</p><button>New note</button></main></body></html>',
} });
try {
const { trace, stepTexts, finishReason, responseMessages } = await runTurn({
workspace,
model,
userPrompt: '/impeccable polish index.html. Keep this pass small: improve the button spacing only, preserving the page content and structure.',
maxSteps: 12,
denyBash,
contextOnlyBash: !denyBash,
});
const allText = stepTexts.join('\n');
logTrace('S19', denyBash ? 'denied-launcher' : 'successful-launcher', modelId, trace, { finishReason, text: allText });
assert.notEqual(finishReason, 'length', 'a truncated response is not a completed fallback');
if (denyBash) {
assert.ok(trace.toolCalls.some((call) => call.name === 'bash' && call.denied && /impeccable\s+context\b/.test(call.input.command)), 'must encounter an actual denied context attempt');
} else {
assert.ok(trace.bashOutputs.some((out) => out.startsWith('exit=0\n')), 'the control must execute the real context loader successfully');
}
const writeIndex = trace.toolCalls.findIndex((call) => call.mutatedPaths.includes('index.html'));
assert.ok(writeIndex >= 0, 'must continue to the requested edit, not just load references');
for (const filename of [...(denyBash ? ['PRODUCT.md', 'DESIGN.md'] : []), 'reference/polish.md', 'reference/craft-floor.md']) {
const readIndex = trace.toolCalls.findIndex((call) => call.name === 'read' && call.succeeded && (call.input.path === filename || call.input.path.endsWith(`/${filename}`)));
assert.ok(readIndex >= 0 && readIndex < writeIndex, `${filename} must actually be read before editing`);
}
const warning = /(?:context|launcher|bash)[^.!?\n]{0,160}(?:denied|refused|unavailable|blocked|could(?:n't| not)|cannot|can't|did(?:n't| not)|fail|unable)|(?:denied|refused|unavailable|blocked|could(?:n't| not)|cannot|can't|unable)[^.!?\n]{0,160}(?:context|launcher|bash)/i;
const assistantBlocks = responseMessages.filter((message) => message.role === 'assistant')
.flatMap((message) => typeof message.content === 'string' ? [{ type: 'text', text: message.content }] : message.content);
const warningIndex = assistantBlocks.findIndex((block) => block.type === 'text' && warning.test(block.text));
const writeBlockIndex = assistantBlocks.findIndex((block) => block.type === 'tool-call' && block.toolName === 'write');
if (denyBash) assert.ok(warningIndex >= 0 && writeBlockIndex > warningIndex, 'must disclose the failed context launcher before editing, not only in the final summary');
assert.ok(!trace.toolCalls.some((call) => call.mutatedPaths.some((p) => /(?:^|\/)(?:PRODUCT|DESIGN)\.md$/.test(p))), 'must not fabricate or replace project context');
} finally {
cleanupWorkspace(workspace);
}
});
}
it('scenario 18: explicit command request takes precedence over workflow advice', async () => {
const workspace = prepareWorkspace({ files: WORKFLOW_ADVICE_FILES });
try {