mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
* security: isolate every gstack Bun entry point from the audited checkout's .env and bunfig.toml Bun auto-loads .env, .env.local and .env.<NODE_ENV> from its cwd and runs a cwd bunfig.toml preload before the program starts. gstack runs inside the repo it audits, so a committed .env could steer state roots, the claude binary or spend caps, and a bunfig preload ran arbitrary code in every bin and hook. Every bun shebang, shell and skill caller, TypeScript spawn and compiled build now disables both (--no-env-file --config=/dev/null, bunIsolationArgs(), --no-compile-autoload-dotenv/bunfig), with a ratchet and hostile-fixture regressions. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: name the directly executed scripts in the isolation ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: follow the isolation flags in touchfiles, copy lists, recipe assertions and the harness pin Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: keep browse/src/bun-child-config.ts unchanged so the native cookie qualification still binds Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(windows): the empty bunfig outlives its process so the timeline hook's detached repair child can read it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.159.0 security: gstack's Bun entry points ignore the audited repo's .env and bunfig.toml (S1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: plan-mode render pattern accepts the isolation flags Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
140 lines
6.6 KiB
TypeScript
140 lines
6.6 KiB
TypeScript
import { toShellPath, type TemplateContext } from './types';
|
|
import * as path from 'path';
|
|
import { getHostConfig } from '../../hosts';
|
|
import { runtimeRootPrelude } from './runtime-root';
|
|
|
|
/** Claude's scoped hook enforces the parent publication boundary during /autoplan. */
|
|
export function generateAutoplanPublicationHook(ctx: TemplateContext, args?: string[]): string {
|
|
if (ctx.skillName !== 'autoplan' || args?.length) {
|
|
throw new Error('AUTOPLAN_PUBLICATION_HOOK is only valid in autoplan without arguments');
|
|
}
|
|
if (ctx.host !== 'claude') return '';
|
|
|
|
// Use the same installed runtime path as Claude's existing skill hooks.
|
|
// Emitting this through a host-specific resolver also keeps other hosts free
|
|
// of the generator's destructive-command advisory for safety hooks.
|
|
const unavailable = JSON.stringify({ hookSpecificOutput: {
|
|
hookEventName: 'PreToolUse', permissionDecision: 'deny',
|
|
permissionDecisionReason: 'Autoplan publication guard is unavailable. Restore the installed autoplan/bin/phase-publication-hook before continuing this skill.',
|
|
} });
|
|
const shellWord = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
const script = `S="${toShellPath(ctx.paths.skillRoot)}/autoplan/bin/phase-publication-hook"
|
|
if [ -f "$S" ]; then exec bash "$S"; fi
|
|
printf '%s\\n' ${shellWord(unavailable)}`;
|
|
const command = `bash -c ${shellWord(script)}`;
|
|
// Keep each tool literal: the early-question launchers admit these hooks
|
|
// through their existing finite matcher policy.
|
|
const entries = ['Read', 'Agent'].map(tool => ` - matcher: "${tool}"
|
|
hooks:
|
|
- type: command
|
|
command: ${JSON.stringify(command)}
|
|
statusMessage: "Checking Autoplan phase publication..."`).join('\n');
|
|
return `hooks:\n PreToolUse:\n${entries}`;
|
|
}
|
|
|
|
/**
|
|
* {{CEO_MODE_HANDOFF_HOOK}}: /plan-ceo-review's PostToolUse hook that shows the
|
|
* Step 0E mode handoff line to the user as a system message (Claude only), so
|
|
* the line is visible even when the model paraphrases it in chat.
|
|
*/
|
|
export function generateCeoModeHandoffHook(ctx: TemplateContext, args?: string[]): string {
|
|
if (ctx.skillName !== 'plan-ceo-review' || args?.length) {
|
|
throw new Error('CEO_MODE_HANDOFF_HOOK is only valid in plan-ceo-review without arguments');
|
|
}
|
|
if (ctx.host !== 'claude') return '';
|
|
const shellWord = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
const script = `S="${toShellPath(ctx.paths.skillRoot)}/plan-ceo-review/bin/mode-handoff-hook"
|
|
if [ -f "$S" ]; then exec bash "$S"; fi
|
|
exit 0`;
|
|
return `hooks:
|
|
PostToolUse:
|
|
- matcher: "Bash"
|
|
hooks:
|
|
- type: command
|
|
command: ${JSON.stringify(`bash -c ${shellWord(script)}`)}`;
|
|
}
|
|
|
|
/**
|
|
* {{INVOKE_SKILL:skill-name}} — emits prose instructing Claude to read
|
|
* another skill's SKILL.md and follow it, skipping preamble sections.
|
|
*
|
|
* Supports optional skip= parameter for additional sections to skip:
|
|
* {{INVOKE_SKILL:plan-ceo-review:skip=Outside Voice,Design Outside Voices}}
|
|
*/
|
|
export function generateInvokeSkill(ctx: TemplateContext, args?: string[]): string {
|
|
const skillName = args?.[0];
|
|
if (!skillName || skillName === '') {
|
|
throw new Error('{{INVOKE_SKILL}} requires a skill name, e.g. {{INVOKE_SKILL:plan-ceo-review}}');
|
|
}
|
|
|
|
// Parse optional skip= parameter from args[1+]
|
|
const extraSkips = (args?.slice(1) || [])
|
|
.filter(a => a.startsWith('skip='))
|
|
.flatMap(a => a.slice(5).split(','))
|
|
.map(s => s.trim())
|
|
.filter(Boolean);
|
|
|
|
const DEFAULT_SKIPS = [
|
|
'Preamble (run first)',
|
|
'AskUserQuestion Format',
|
|
'Completeness Principle — Boil the Ocean',
|
|
'Search Before Building',
|
|
'Contributor Mode',
|
|
'Completion Status Protocol',
|
|
'Telemetry (run last)',
|
|
'Step 0: Detect platform and base branch',
|
|
'Review Readiness Dashboard',
|
|
'Plan File Review Report',
|
|
'Prerequisite Skill Offer',
|
|
'Plan Status Footer',
|
|
];
|
|
|
|
const allSkips = [...DEFAULT_SKIPS, ...extraSkips];
|
|
|
|
return `Read the \`/${skillName}\` skill file at \`${ctx.paths.skillRoot}/${skillName}/SKILL.md\` using the Read tool.
|
|
|
|
**If unreadable:** Skip with "Could not load /${skillName} — skipping." and continue.
|
|
|
|
Follow its instructions from top to bottom, **skipping these sections when present** (already handled by the parent skill):
|
|
${allSkips.map(s => `- ${s}`).join('\n')}
|
|
|
|
Execute every other section at full depth. When the loaded skill's instructions are complete, continue with the next step below.`;
|
|
}
|
|
|
|
/** Autoplan reads methodology from this host's skill registry, not its runtime assets. */
|
|
export function generateAutoplanReviewFile(ctx: TemplateContext, args?: string[]): string {
|
|
const skill = args?.[0];
|
|
if (!skill || !['plan-ceo-review', 'plan-design-review', 'plan-devex-review', 'plan-eng-review'].includes(skill)) {
|
|
throw new Error('AUTOPLAN_REVIEW_FILE requires an autoplan review skill');
|
|
}
|
|
const withSections = args?.[1] === 'with-sections';
|
|
if ((args?.length ?? 0) > 2 || (args?.[1] !== undefined && !withSections)) {
|
|
throw new Error('AUTOPLAN_REVIEW_FILE only accepts with-sections');
|
|
}
|
|
// Every host prepares an explicit bound artifact before create. Inline hosts
|
|
// supply one complete source file; Claude supplies main plus its carved section.
|
|
if (withSections) {
|
|
const phase = skill === 'plan-devex-review' ? 'dx' : skill.split('-')[1]!;
|
|
return `\`methodologyPath\` from \`bun "<SNAPSHOT_TOOL>" methodology ${phase} "<REVIEW_SKILL>" "<RESTORE_PATH>"\``;
|
|
}
|
|
if (ctx.host === 'claude') return `\`${ctx.paths.skillRoot}/${skill}/SKILL.md\``;
|
|
|
|
const host = getHostConfig(ctx.host);
|
|
const file = `gstack-${skill}/SKILL.md`;
|
|
const local = `${path.posix.dirname(host.localSkillRoot)}/${file}`;
|
|
const global = `~/${path.posix.dirname(host.globalRoot)}/${file}`;
|
|
// Resolve from the discovered entrypoint's directory: GSTACK_ROOT is an
|
|
// independently configurable runtime asset tree, not a skill registry.
|
|
// This also preserves custom CODEX_HOME installations without guessing HOME.
|
|
// Other hosts inline the review sections into this full registry file.
|
|
return `the sibling registry file \`../${file}\`, relative to the installed \`/autoplan\` SKILL.md directory (local: \`${local}\`; global: \`${global}\`${ctx.host === 'codex' ? ', or the corresponding skills directory under CODEX_HOME when configured' : ''})`;
|
|
}
|
|
|
|
/** Resolve once to a literal path; later phase commands run in fresh shells. */
|
|
export function generateAutoplanSnapshotTool(ctx: TemplateContext): string {
|
|
return `\`\`\`bash
|
|
${runtimeRootPrelude(ctx)}
|
|
bun --no-env-file --config=/dev/null -e 'console.log(require("fs").realpathSync(process.argv[1]))' "${toShellPath(ctx.paths.binDir)}/gstack-autoplan-snapshot.ts"
|
|
\`\`\``;
|
|
}
|