@sabaiway/agent-workflow-kit 14.25.2 → 14.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +9 -8
- package/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +7 -7
- package/bridges/codex-cli-bridge/bin/codex-review.sh +1 -1
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bootstrap.md +2 -4
- package/references/modes/jev.md +2 -2
- package/references/modes/mcp.md +1 -1
- package/references/modes/recommendations.md +19 -19
- package/references/modes/upgrade.md +3 -3
- package/references/modes/velocity.md +4 -4
- package/tools/apply-danger-check.mjs +369 -0
- package/tools/bridge-sandbox-recipe.mjs +102 -15
- package/tools/bridge-state-dirs.mjs +94 -0
- package/tools/init-project.mjs +27 -29
- package/tools/jev-connect.mjs +2 -2
- package/tools/jev-guide.mjs +2 -2
- package/tools/manifest/schema.md +8 -6
- package/tools/recommendations.mjs +41 -42
- package/tools/velocity-profile.mjs +174 -111
- package/tools/write-lanes.mjs +56 -32
|
@@ -113,12 +113,12 @@ Requires: ${CLAUDE_SKILL_DIR}/references/shared/report-footer.md · ${CLAUDE_SKI
|
|
|
113
113
|
- **A skip line that contradicts the observed tree is a FINDING, never pasted as neutral.** Every ensure skip names the fact it proved (the probes that answered absent, the layout it read); if the tree you can see disproves that reason — a "no Node" skip beside deployed Node scripts, a "not deployed" skip beside a stamped `docs/ai` — do not relay the line as an outcome: raise it in the report as a defect of the tool, with the contradicting fact named, and stop short of any step that would build on the skip.
|
|
114
114
|
- **Report step 3's outcome in plain language** — for **each** `pointers` slot (workflow-methodology, orchestration-recipes and autonomy-policy) whether it was *added*, was *already present* (nothing changed), or was *skipped* (the soft-skip from step 3, with its reason — over the line limit / engine too old / the autonomy pointer's anchor absent); the **eight project-configuration ensure** (`configs`) lines exactly as the one ensure run composed them (the ensures and their outcome tokens are enumerated in step 3), each rendered in plain language: what was created, what was left exactly as the user wrote it, and — for a `failed` line — what stopped it; for the navigator, relay the **late** `--only index` line, not the early one; the **`gates-migration`** result — *nothing to migrate*, the shown plan *applied* on your explicit yes, or the plan *left unapplied* (consent not given), with any INERT checker or CUSTOMIZED entries named; the **placed-bridge refresh** (`bridges`) outcome — paste the tool's per-bridge lines verbatim (they are already plain: *refreshed* / *already current* / *skipped — not placed* / `skipped-readonly` with its re-scan verdict / *could not refresh* + recovery); the **agent-rules lens** (`lens`) outcome (*refreshed* / *already current* / *custom edit preserved + note* / *file absent* / *engine too old* / *over the line cap*) and the **Communication-section** outcome (its own set: refreshed / already current / custom preserved + note / section absent — noted / over the cap — refused) and the **story-sessions section** outcome (its own set: refreshed / already current / custom edit preserved + note / section absent — noted / over the file's line cap — refused); the **full-flow offer** (`tier`) lines; the **bridge-settings reconcile** (`bridge-settings`) outcome (paste the tool's line(s) verbatim); and, for a hidden deployment, whether the hidden-mode footprint (`footprint`) was *moved to project-local*, was *already project-local* (nothing changed), or needed a question (ambiguous visibility / a leftover machine-wide block). Plain wording only — never the reconcile/slot/anchor/marker terms (the never-leak-kit-internals Gotcha — `${CLAUDE_SKILL_DIR}/references/shared/deploy-tail.md`).
|
|
115
115
|
- **Never surface the structure number on this exit.** Whatever step 3 did, do **not** recite the `docs/ai` structure version, the internal versioning vocabulary, or the two-axes note here — the number is inert on an equal-head exit; it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md` (shown at the never-downgrade STOP, the explicit status view, or on an explicit ask). Frame the success itself per the final bullet: if step 3 changed anything, say **what changed** in plain human terms; only a pure zero-diff no-op is *settings already current — no update needed*.
|
|
116
|
-
- **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent
|
|
117
|
-
- **Recommendations lanes — both exits.** The
|
|
116
|
+
- **Render the mandatory Recommendations section — on this exit too, BEFORE the footer.** Run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language: every fact, count and item from the tool, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; show the raw tool block on request. The section is present-even-when-empty (with everything optimal the body is exactly `no recommendations — flow optimal.`) and VERDICT-FIRST — the composed verdict line renders from the frozen templates `{K} item(s) need attention` / `nothing is broken` / `{N} optional recommendation(s), apply any you want` / `optimality NOT attested — {M} probe check(s) skipped`. Then OFFER the consent route: the user picks items in plain language; surface each picked item's posture note and get the explicit confirm; run its lane's rendered lines — chat: the one-liner; harness: the check line, then on the yes to its result the apply line; user: the user's own step — per the full consent route in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. Pinned order on this exit: Recommendations block → optional applies → report footer → the commit ask (the advisor/apply lane never lands after the commit ask).
|
|
117
|
+
- **Recommendations lanes — both exits.** The three lanes (`chat`, `harness`, `user`) and the consent route live in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`; `init` stays a route the user may take.
|
|
118
118
|
- **Live host/session facts are tool-composed only.** Any claim this report makes about the current host or session state — prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts — must trace to **live tool output** from **this session** (the lines you just composed, or a probe you ran this run); a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection. Full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`.
|
|
119
119
|
- **Print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason). The welcome mat closes on **one** caveat-aware next step (a behind member first, else `setup` / `recipes` / `velocity` / `agents` / `hook`).
|
|
120
120
|
- **Then ask before committing — never auto-commit.** If step 3 added the slot (or anything else changed), report it and ask. If step 3 was a pure zero-diff no-op and nothing else changed, give the plain **settings already current — no update needed** message (the *Success state* contract in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`) and still print the read-only version block (installed package versions) + backend line — but **no `docs/ai` structure version and no two-axes note** (nothing changed, so the number is inert here).
|
|
121
121
|
5. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` diff (entries newer than the project's stamp).
|
|
122
122
|
6. **Collect the migration answers FIRST, then apply.** Run `node ${CLAUDE_SKILL_DIR}/tools/migration-notes.mjs --cwd <project>` and relay its stdout lines verbatim, in order; a non-zero exit relays its one named line and STOPs, except `stamp-absent`, which routes back to step 1. Then run the preview `node ${CLAUDE_SKILL_DIR}/tools/migration-blocks.mjs --cwd <project>`. If its lines say both blocks are missing (pre-1.1.0), ask the two questions as ONE structured multi-question prompt; record each answer individually, write nothing until ALL are answered, and carry the answers into the migrations below: a migration whose answer was already collected never re-asks; a single missing block keeps its single ask (step 7). Then apply exactly the notes the tool listed, in its order ("only those newer than the project's stamp" is why, never a second selection). Migrations are **idempotent** — safe to re-run.
|
|
123
123
|
7. Reconcile drift: add any kernel files/scripts the project is missing; never clobber project-authored content (their `decisions.md`, `known_issues.md`, page specs stay). Any user question a migration raises follows the same rule as bootstrap — **structured multiple-choice where supported** (`AskUserQuestion` in Claude Code), otherwise prose. For a block the preview names as needing its answer, **ask the user their conversational language** (`--language`) or **whether the agent may attribute work to itself / AI** (`--attribution`, defaulting to `off`), never re-asking a step-6 answer; then re-run the preview with `--apply` and the answers, relaying its lines. Exit 1 `answer-missing`: ask exactly what its lines name and re-run `--apply` once; a second one STOPs. Any other exit 1 is a named refusal: relay it with its recovery (`cap`: trim `AGENTS.md`, re-run) and STOP before the finalizer and the stamp. **Then, before the stamp, re-run the LATE navigator finalizer:** the migrations of steps 6–7 may have changed `docs/ai` files, so run the one op again — `node ${CLAUDE_SKILL_DIR}/tools/ensure-configs.mjs --reconcile --only index --cwd <project>` — and relay THAT line in the step-8 report (idempotent: an untouched tree reports `already-current`; a failure STOPs this upgrade like any other ensure).
|
|
124
|
-
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`4.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **eight project-configuration ensure** (`configs`) lines (enumerated in step 3) — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** + **story-sessions section** outcomes (same outcome sets as step 4), the **full-flow offer** (`tier`) outcome, and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language (every fact, count and item, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent
|
|
124
|
+
8. Re-stamp `docs/ai/.workflow-version` to the **deployment-lineage head** (`4.0.0`, not the package version — mechanics unchanged: the atomic write to the stamp file). In the report, **describe what the upgrade changed in plain human terms** — which parts of their `docs/ai` are now different (the migrations that ran), plus the step-3 run-list outcomes, each as step 4 words it: the `pointers` slots, the `footprint` visibility verdict (and what a consented re-run migrated), the **eight project-configuration ensure** (`configs`) lines (enumerated in step 3) — the navigator one being the late `--only index` run at the end of step 7, the **`gates-migration`** result, the **placed-bridge refresh** (`bridges`) lines (pasted verbatim), the **agent-rules lens** (`lens`) + **Communication-section** + **story-sessions section** outcomes (same outcome sets as step 4), the **full-flow offer** (`tier`) outcome, and the **bridge-settings reconcile** (`bridge-settings`) outcome (line(s) pasted verbatim) — rather than reciting a version number; **omit the raw structure number**, and do **not** print the two-axes note here (it belongs to *Version disclosure* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`, on demand only). Then **render the mandatory Recommendations section**: run `node ${CLAUDE_SKILL_DIR}/tools/recommendations.mjs --cwd <project-root>` and PRESENT its output — from the `## Recommendations (agent-workflow)` header — in the user's conversational language (every fact, count and item, nothing added or dropped; commands, paths, hosts and rule strings byte-exact; raw tool block on request; present-even-when-empty: `no recommendations — flow optimal.`), then OFFER the consent route (per picked item: posture note → explicit confirm → chat: the one-liner; harness: the check line, then on the yes to its result the apply line; user: the user's own step — `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Every current host/session claim in this report is tool-composed only** — prompts fired, sandbox scope, whether a bypass was needed, network reachability and approval counts must trace to **live tool output** from **this session**, a memory/handover snapshot is **context, never report facts**, and an unbacked claim is **omitted or explicitly marked unverified** (full clause: *Live host/session facts* in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`). Then **print the report footer** in the canonical order (version block → one-line backend-status line → welcome mat — the shared contracts in `${CLAUDE_SKILL_DIR}/references/shared/report-footer.md`; rendered from the helpers, same host-can't-run skip-with-reason; the welcome mat closes on one caveat-aware next step). Then **ask before committing** — the pinned order on this exit is: Recommendations block → optional applies → report footer → the commit ask.
|
|
@@ -50,11 +50,11 @@ Honesty notes: tier entries get **NO PreToolUse-hook residual coverage — with
|
|
|
50
50
|
|
|
51
51
|
**The boundary of that qualifier, stated so it is not guessed.** It covers claims about `sandbox.*` keys — the block whose honouring was observed to vary by host. It deliberately does NOT cover `permissions.*` semantics (an allow rule auto-approving a command, `local > project` precedence for a non-sandbox key): that is the harness's own documented permission model, the layer this whole mode rests on, and hedging it would say nothing true. **Stated residual:** which sentences count as settings-derived runtime claims is decided by review, not by a checker — deciding whether a sentence asserts a runtime effect is a question about prose meaning, and this family deliberately has no mechanism that judges that (the same limit that retired the byte-scanning deny rung). A new claim-bearing line therefore has to carry the qualifier by authorship; the acceptance criterion is that every line stating what a `sandbox.*` key DOES carries it, and the tests pin the surfaces that exist today. What does NOT change is the CLASSIFICATION: which red-line a key would weaken, and that a proven bridge-tier exclusion is a note rather than a weakening, are properties of the declaration and stay flat statements.
|
|
52
52
|
|
|
53
|
-
**The `--bridge-tier` (own opt-in, AD-044).** Seeds
|
|
53
|
+
**The `--bridge-tier` (own opt-in, AD-044).** Seeds the USED bridges from every recipe's review and execute slots in `docs/ai/orchestration.json`, resolved over host readiness to the effective recipe: a degraded recipe uses its resolved providers; an absent config takes the computed default. A bridge no slot uses gets nothing. Per used review role it seeds `Bash(<wrapper> code:*)` into `permissions.allow` and, with agy-review used, the quoted grounding pre-step rule. Per used role it seeds `<wrapper> *` into `sandbox.excludedCommands`, the manifest's `networkHosts` into `sandbox.network.allowedDomains`, and its resolved `writableDirs` into `sandbox.filesystem.allowWrite`, home-relative under the home and absolute outside it, with env overrides honoured. All entries land in the **project** `.claude/settings.json`, preserving existing entries and sibling keys. **Where the host honors the settings sandbox keys**, an exclusion means the wrapper runs outside the sandbox; a host means every sandboxed command of the project can reach it; a dir means every sandboxed command can write it — each widening is part of this consent. A session-imposed sandbox may ignore those keys; its lane is in `${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`. An exclusion placed only in `settings.local.json` was live-observed NOT to route the command outside the sandbox (2026-07-11: the wrapper then starts sandboxed and dies on a read-only HOME), so hand-wiring the local file is no substitute for this tier. Under a harness-managed session sandbox (live-observed 2026-07-11/12) an IDE-managed sandbox ignores `sandbox.network.allowedDomains` and `sandbox.filesystem.allowWrite` in BOTH settings scopes — its own per-host consents govern egress, and the durable lanes there are the session sandbox config (the hosts plus the CLI state-dir writes) or the per-run consented bypass. Each bridge's observed egress hosts are declared in its `capability.json` `networkHosts` — the single documentation source (this doc retypes no host list); an apex domain is NOT covered by its `*.`-wildcard form, so the manifests carry both forms where observed. The upgrade Recommendations advisor surfaces the same recipe (hosts ∪ resolved `writableDirs`) as the `sandbox-lane` discoverability item — a consent-gated **ack writer** converging on a neutral fingerprint acknowledgement recorded into the family-owned `docs/ai/acks.json`, with the posture notes at the consent moment (`${CLAUDE_SKILL_DIR}/references/modes/recommendations.md`). **Bare-entry reading (measured 2026-10-06):** an exclusion uses Bash rule syntax; a bare name matches only the argument-free command, so the tier writes the form with ` *`; an earlier bare entry stays and does not satisfy that surface. Where an execute slot delegates, codex-exec gets its exclusion, hosts and state dirs, but no allow rule, so a delegated run keeps its prompt. Only the code review mode is auto-allowed; plan/diff keep their prompt. The grounding rule uses the double-quoted absolute kit path the procedures advisor renders; an unseedable path is a stated skip. Its writer guard accepts only system-temp scratch or a fresh gitignored in-repo destination, refusing tracked, non-ignored, existing in-repo, outside-repo non-temp and symlink/non-regular destinations. The seeded byte-forms (this list IS the documented-invocation source for the bridge tier):
|
|
54
54
|
|
|
55
|
-
- `Bash(codex-review code:*)` — the code-mode
|
|
56
|
-
- `Bash(agy-review code:*)` — the code-mode
|
|
57
|
-
- `
|
|
55
|
+
- `Bash(codex-review code:*)` — the review code-mode allow rule; `codex-review *` — its sandbox.excludedCommands entry.
|
|
56
|
+
- `Bash(agy-review code:*)` — the review code-mode allow rule; `agy-review *` — its sandbox.excludedCommands entry; `Bash(node "${CLAUDE_SKILL_DIR}/tools/grounding.mjs":*)` — its quoted grounding pre-step rule.
|
|
57
|
+
- `codex-exec *` — the sandbox.excludedCommands entry for a used execute role; no allow rule.
|
|
58
58
|
|
|
59
59
|
**Consented posture (printed on every `--bridge-tier` run, stated not pretended away):** an auto-allowed review wrapper runs **UNATTENDED** — it reads any repo file it is pointed at and sends the assembled payload to its **subscription backend**; prefix rules cannot inspect arguments, so a code-mode argument that names a readable file (agy's `--facts`/`--decided`) rides the same consent. That is the tier's purpose (unattended council review runs) and its residual. Tier entries get no PreToolUse-hook coverage — the same documented residual class as the autonomy red-line rules. The grounding entry's writer surface is bounded by `grounding.mjs`'s own scratch-destination guard (a tracked or in-repo-not-ignored `--out` is refused by the tool itself).
|
|
60
60
|
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
CLAUDE_DIR, RENDER_OWNED_REDLINE_RULES,
|
|
10
|
+
SAFE_DEFAULT_MODES, SETTINGS_FILE, SETTINGS_LOCAL_FILE, UNIVERSAL_READONLY_ALLOWLIST,
|
|
11
|
+
deriveKitToolsAllowlist, detectAtRoot, mergeAutonomySettings,
|
|
12
|
+
writeAutonomyProfile, writeVelocityProfile,
|
|
13
|
+
} from './velocity-profile.mjs';
|
|
14
|
+
import { KIT_GROUNDING_TOOL, bundledSandboxRecipe, usedBridges } from './bridge-sandbox-recipe.mjs';
|
|
15
|
+
import { mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
|
|
16
|
+
import { loadConfig } from './orchestration-config.mjs';
|
|
17
|
+
import { HOOK_FILE_REL, HOOKS_DIR, buildHookSettingsEntry, mergeHookEntry, writeGateHook } from './gate-hook.mjs';
|
|
18
|
+
import { writeMcp } from './mcp.mjs';
|
|
19
|
+
import { ENABLED_KEY, MCP_JSON_REL, SERVERS_KEY, SERVER_NAME, allowRulesFor, buildServerEntry } from './mcp-registration.mjs';
|
|
20
|
+
import { AGENTS_DIR, writeCheapAgents } from './cheap-agents.mjs';
|
|
21
|
+
import { planFor } from './jev-skill.mjs';
|
|
22
|
+
import { SKILL_FILES, claudeDirOf, skillTargets } from './jev-facts.mjs';
|
|
23
|
+
import { findOnPath } from './detect-backends.mjs';
|
|
24
|
+
import { consoleArgv } from './write-lanes.mjs';
|
|
25
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
26
|
+
import { dirCovers as covers } from './declared-paths.mjs';
|
|
27
|
+
|
|
28
|
+
const TOOLS = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const SKILL_VARIANTS = ['jev-skill', 'jev-skill.earlier'];
|
|
30
|
+
const VELOCITY_VARIANTS = ['velocity-core', 'kit-tools-tier', 'bridge-tier'];
|
|
31
|
+
const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing'];
|
|
32
|
+
const MCP_VARIANTS = ['mcp-channel', 'mcp-channel.differing'];
|
|
33
|
+
const AGENT_VARIANTS = ['agents', 'executor-vehicle'];
|
|
34
|
+
const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, ...MCP_VARIANTS, ...AGENT_VARIANTS, ...SKILL_VARIANTS];
|
|
35
|
+
const CREDENTIALS = 'sandbox.credentials.envVars';
|
|
36
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
37
|
+
const stable = (value) => object(value)
|
|
38
|
+
? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
|
|
39
|
+
: Array.isArray(value) ? `[${value.map(stable).join(',')}]` : JSON.stringify(value);
|
|
40
|
+
const equal = (left, right) => stable(left) === stable(right);
|
|
41
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
42
|
+
const textValue = (value) => typeof value === 'string' ? value : JSON.stringify(value);
|
|
43
|
+
|
|
44
|
+
export const CHANGE_CLASSES = Object.freeze([
|
|
45
|
+
'hosts reachable', 'paths writable', 'commands outside the sandbox',
|
|
46
|
+
'prompts removed', 'commands the harness runs', 'nothing widened',
|
|
47
|
+
]);
|
|
48
|
+
export const KEY_TABLE = Object.freeze({
|
|
49
|
+
'sandbox.network.allowedDomains': 0,
|
|
50
|
+
'sandbox.filesystem.allowWrite': 1,
|
|
51
|
+
'sandbox.excludedCommands': 2,
|
|
52
|
+
'permissions.allow': 3,
|
|
53
|
+
'permissions.defaultMode': 3,
|
|
54
|
+
'sandbox.autoAllowBashIfSandboxed': 3,
|
|
55
|
+
'permissions.ask': 3,
|
|
56
|
+
'permissions.deny': 3,
|
|
57
|
+
'sandbox.enabled': 3,
|
|
58
|
+
'hooks.PreToolUse': 4,
|
|
59
|
+
[ENABLED_KEY]: 4,
|
|
60
|
+
[SERVERS_KEY]: 4,
|
|
61
|
+
[CREDENTIALS]: 5,
|
|
62
|
+
});
|
|
63
|
+
const SCALARS = ['permissions.defaultMode', 'sandbox.enabled', 'sandbox.autoAllowBashIfSandboxed'];
|
|
64
|
+
const valid = (key, value, element = false) => {
|
|
65
|
+
if (key.startsWith(`${SERVERS_KEY}.`) || key === SERVERS_KEY) return object(value);
|
|
66
|
+
if (key === 'permissions.defaultMode') return typeof value === 'string';
|
|
67
|
+
if (SCALARS.includes(key)) return typeof value === 'boolean';
|
|
68
|
+
if (!element) return Array.isArray(value) && value.every((entry) => valid(key, entry, true));
|
|
69
|
+
if (key === CREDENTIALS) return object(value) && typeof value.name === 'string' && typeof value.mode === 'string';
|
|
70
|
+
return key === 'hooks.PreToolUse' ? object(value) : typeof value === 'string';
|
|
71
|
+
};
|
|
72
|
+
const keyOf = (key) => key.startsWith(`${SERVERS_KEY}.`) ? SERVERS_KEY : key;
|
|
73
|
+
|
|
74
|
+
export const diffFiles = (before, after) => {
|
|
75
|
+
const entries = [];
|
|
76
|
+
const scalar = (key, left, right) => {
|
|
77
|
+
if (equal(left, right)) return;
|
|
78
|
+
if (left === undefined) entries.push({ key, change: 'added', value: right });
|
|
79
|
+
else if (right === undefined) entries.push({ key, change: 'removed', value: left });
|
|
80
|
+
else entries.push({ key, change: 'altered', value: right, previous: left });
|
|
81
|
+
};
|
|
82
|
+
const walk = (left, right, key) => {
|
|
83
|
+
if (equal(left, right)) return;
|
|
84
|
+
const known = Object.hasOwn(KEY_TABLE, key);
|
|
85
|
+
if (known && [left, right].some((value) => value !== undefined && !valid(key, value))) {
|
|
86
|
+
scalar(key, left, right);
|
|
87
|
+
} else if (key === SERVERS_KEY) {
|
|
88
|
+
for (const name of new Set([...Object.keys(left ?? {}), ...Object.keys(right ?? {})])) {
|
|
89
|
+
scalar(`${key}.${name}`, left?.[name], right?.[name]);
|
|
90
|
+
}
|
|
91
|
+
} else if (known && !SCALARS.includes(key)) {
|
|
92
|
+
const old = left ?? [];
|
|
93
|
+
const next = right ?? [];
|
|
94
|
+
const unpaired = old.filter((value) => !next.some((entry) => equal(value, entry)));
|
|
95
|
+
const changes = next.filter((value) => !old.some((entry) => equal(value, entry))).map((value) => {
|
|
96
|
+
const at = key === CREDENTIALS ? unpaired.findIndex((entry) => entry.name === value.name) : -1;
|
|
97
|
+
return at < 0 ? { key, change: 'added', value } : { key, change: 'altered', value, previous: unpaired.splice(at, 1)[0] };
|
|
98
|
+
});
|
|
99
|
+
for (const value of unpaired) entries.push({ key, change: 'removed', value });
|
|
100
|
+
entries.push(...changes);
|
|
101
|
+
} else if (!known && (key === '' || Object.keys(KEY_TABLE).some((path) => path.startsWith(`${key}.`)))
|
|
102
|
+
&& [left, right].every((value) => value === undefined || object(value))) {
|
|
103
|
+
for (const name of new Set([...Object.keys(left ?? {}), ...Object.keys(right ?? {})])) {
|
|
104
|
+
walk(left?.[name], right?.[name], key ? `${key}.${name}` : name);
|
|
105
|
+
}
|
|
106
|
+
} else scalar(key, left, right);
|
|
107
|
+
};
|
|
108
|
+
walk(before ?? {}, after ?? {}, '');
|
|
109
|
+
const order = Object.keys(KEY_TABLE);
|
|
110
|
+
const rank = (key) => order.includes(keyOf(key)) ? order.indexOf(keyOf(key)) : order.length;
|
|
111
|
+
return entries.sort((a, b) => rank(a.key) - rank(b.key));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const classOf = ({ key, change, value }) => {
|
|
115
|
+
const base = keyOf(key);
|
|
116
|
+
if (!Object.hasOwn(KEY_TABLE, base)) return null;
|
|
117
|
+
let index = KEY_TABLE[base];
|
|
118
|
+
if (change === 'removed') index = ['permissions.ask', 'permissions.deny', CREDENTIALS].includes(key) ? 3 : 5;
|
|
119
|
+
else if (['permissions.ask', 'permissions.deny'].includes(key)) index = 5;
|
|
120
|
+
else if (key === 'permissions.defaultMode') index = value === 'acceptEdits' ? 3 : 5;
|
|
121
|
+
else if (key === 'sandbox.enabled') index = value === false ? 3 : 5;
|
|
122
|
+
else if (key === 'sandbox.autoAllowBashIfSandboxed') index = value === true ? 3 : 5;
|
|
123
|
+
return CHANGE_CLASSES[index];
|
|
124
|
+
};
|
|
125
|
+
export const judgeChange = ({ variant, files, scope }) => {
|
|
126
|
+
const entries = [];
|
|
127
|
+
const refused = [];
|
|
128
|
+
for (const { path, before, after } of files) {
|
|
129
|
+
for (const diff of diffFiles(before, after)) {
|
|
130
|
+
const entry = { ...diff, path, class: classOf(diff) };
|
|
131
|
+
entries.push(entry);
|
|
132
|
+
const { key, change } = diff;
|
|
133
|
+
const atKey = (data) => key.split('.').reduce((value, name) => value?.[name], data);
|
|
134
|
+
const wrongType = [atKey(before), atKey(after)].some((value) => value !== undefined && !valid(key, value));
|
|
135
|
+
const reason = entry.class === null ? 'unknown key' : wrongType ? 'wrong type'
|
|
136
|
+
: key === CREDENTIALS && change !== 'added' ? 'credentials entry removed or altered'
|
|
137
|
+
: !scope.admits(entry) ? `beyond the declared scope of ${variant}` : null;
|
|
138
|
+
if (reason) refused.push({ path, key, reason });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { entries, refused };
|
|
142
|
+
};
|
|
143
|
+
const writerFacts = (facts, deps) => ({
|
|
144
|
+
...deps, ...facts,
|
|
145
|
+
detect: deps.detect ?? facts.detect,
|
|
146
|
+
findWrapper: (name) => findOnPath(name, { getenv: facts.env }).state === 'present',
|
|
147
|
+
findOnPath: (name) => findOnPath(name, { getenv: facts.env }),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
export const scopeOf = (variant, facts, deps = {}) => {
|
|
151
|
+
const allowed = {};
|
|
152
|
+
const set = (key, values) => {
|
|
153
|
+
allowed[key] = (value) => values.some((entry) => equal(entry, value));
|
|
154
|
+
};
|
|
155
|
+
if (VELOCITY_VARIANTS.includes(variant)) {
|
|
156
|
+
const allow = [...UNIVERSAL_READONLY_ALLOWLIST];
|
|
157
|
+
if (variant === 'kit-tools-tier') allow.push(...deriveKitToolsAllowlist({ projectDir: facts.root }));
|
|
158
|
+
if (variant === 'bridge-tier') {
|
|
159
|
+
const detect = deps.detect ?? facts.detect ?? (() => detectAtRoot(facts.root, { env: facts.env, home: facts.home }));
|
|
160
|
+
const used = usedBridges(loadConfig(facts.root).config, detect());
|
|
161
|
+
const recipe = bundledSandboxRecipe(used, { groundingAbsPath: join(facts.toolsDir, '..', KIT_GROUNDING_TOOL) });
|
|
162
|
+
allow.push(...recipe.allow);
|
|
163
|
+
set('sandbox.excludedCommands', recipe.excludedCommands);
|
|
164
|
+
set('sandbox.network.allowedDomains', recipe.hosts);
|
|
165
|
+
set('sandbox.filesystem.allowWrite', stateDirsOf(recipe.dirEntries, facts));
|
|
166
|
+
}
|
|
167
|
+
set('permissions.allow', allow);
|
|
168
|
+
} else if (variant === 'autonomy-render') {
|
|
169
|
+
set('permissions.defaultMode', SAFE_DEFAULT_MODES);
|
|
170
|
+
set('sandbox.enabled', [true]);
|
|
171
|
+
set('sandbox.autoAllowBashIfSandboxed', [true, false]);
|
|
172
|
+
set('permissions.ask', RENDER_OWNED_REDLINE_RULES);
|
|
173
|
+
set('permissions.deny', RENDER_OWNED_REDLINE_RULES);
|
|
174
|
+
allowed[CREDENTIALS] = (value) => value.mode === 'deny';
|
|
175
|
+
} else if (HOOK_VARIANTS.includes(variant)) set('hooks.PreToolUse', [buildHookSettingsEntry()]);
|
|
176
|
+
else if (MCP_VARIANTS.includes(variant)) {
|
|
177
|
+
set(`${SERVERS_KEY}.${SERVER_NAME}`, [buildServerEntry(join(facts.toolsDir, 'mcp-server.mjs'))]);
|
|
178
|
+
set(ENABLED_KEY, [SERVER_NAME]);
|
|
179
|
+
set('permissions.allow', allowRulesFor());
|
|
180
|
+
}
|
|
181
|
+
const targets = SKILL_VARIANTS.includes(variant) ? skillTargets({ home: facts.home,
|
|
182
|
+
claudeDir: facts.claudeDir ?? claudeDirOf(facts.env, facts.home, facts.platform).dir }) : [];
|
|
183
|
+
const filePaths = HOOK_VARIANTS.includes(variant) ? [join(facts.root, HOOK_FILE_REL)] : [];
|
|
184
|
+
const directories = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render'
|
|
185
|
+
|| HOOK_VARIANTS.includes(variant) || MCP_VARIANTS.includes(variant) || AGENT_VARIANTS.includes(variant)
|
|
186
|
+
? [join(facts.root, CLAUDE_DIR)] : [];
|
|
187
|
+
if (HOOK_VARIANTS.includes(variant)) directories.push(join(facts.root, HOOKS_DIR));
|
|
188
|
+
if (AGENT_VARIANTS.includes(variant)) directories.push(join(facts.root, AGENTS_DIR));
|
|
189
|
+
for (const target of targets) {
|
|
190
|
+
for (let at = target.root; at !== target.base && covers(target.base, at); at = dirname(at)) directories.push(at);
|
|
191
|
+
filePaths.push(target.path);
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
admits: ({ path, key, change, value }) => {
|
|
195
|
+
const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL : path === SETTINGS_FILE;
|
|
196
|
+
const gains = variant === 'bridge-tier' || key === 'hooks.PreToolUse' || key === ENABLED_KEY || key === CREDENTIALS;
|
|
197
|
+
return correctFile && (!gains || change === 'added') && Boolean(allowed[key]?.(value));
|
|
198
|
+
},
|
|
199
|
+
admitsPath: (path, directory = false) => directory ? directories.includes(path)
|
|
200
|
+
: filePaths.includes(path) || (AGENT_VARIANTS.includes(variant) && covers(join(facts.root, AGENTS_DIR), path)),
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const jsonAt = (path) => existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
|
|
205
|
+
const halted = (lines) => Object.assign(new Error(lines.join('\n')), { code: 'CHECK_HALTED', lines });
|
|
206
|
+
const maskedFiles = (variant, facts) => {
|
|
207
|
+
const paths = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render' || HOOK_VARIANTS.includes(variant)
|
|
208
|
+
? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
|
|
209
|
+
return paths.flatMap((path) => lstatSync(join(facts.root, path), { throwIfNoEntry: false })?.isCharacterDevice()
|
|
210
|
+
? [`masked: ${path}`] : []);
|
|
211
|
+
};
|
|
212
|
+
export const plannedChange = (variant, facts, deps = {}) => {
|
|
213
|
+
const files = [];
|
|
214
|
+
const writes = [];
|
|
215
|
+
const temporaries = [];
|
|
216
|
+
const writerDeps = writerFacts(facts, deps);
|
|
217
|
+
const addFile = (path, before, after) => {
|
|
218
|
+
files.push({ path, before: before ?? null, after });
|
|
219
|
+
};
|
|
220
|
+
try {
|
|
221
|
+
const masks = maskedFiles(variant, facts);
|
|
222
|
+
if (masks.length) throw halted(masks);
|
|
223
|
+
if (VELOCITY_VARIANTS.includes(variant)) {
|
|
224
|
+
const plan = writeVelocityProfile({ cwd: facts.root, dryRun: true,
|
|
225
|
+
kitTools: variant === 'kit-tools-tier', bridgeTier: variant === 'bridge-tier' }, writerDeps);
|
|
226
|
+
const before = plan.projectSettings.data ?? {};
|
|
227
|
+
let after = structuredClone(before);
|
|
228
|
+
after.permissions = { ...before.permissions,
|
|
229
|
+
allow: [...(before.permissions?.allow ?? []), ...plan.toAdd, ...plan.tierToAdd, ...plan.bridgeToAdd] };
|
|
230
|
+
if (plan.excludedToAdd.length || plan.hostsToAdd.length) {
|
|
231
|
+
after.sandbox = { ...before.sandbox };
|
|
232
|
+
if (plan.excludedToAdd.length) after.sandbox.excludedCommands = [...(before.sandbox?.excludedCommands ?? []), ...plan.excludedToAdd];
|
|
233
|
+
if (plan.hostsToAdd.length) after.sandbox.network = { ...before.sandbox?.network,
|
|
234
|
+
allowedDomains: [...(before.sandbox?.network?.allowedDomains ?? []), ...plan.hostsToAdd] };
|
|
235
|
+
}
|
|
236
|
+
after = mergeAllowWrite(after, plan.dirsToAdd);
|
|
237
|
+
addFile(SETTINGS_FILE, plan.projectSettings.data, after);
|
|
238
|
+
} else if (variant === 'autonomy-render') {
|
|
239
|
+
const render = writeAutonomyProfile({ cwd: facts.root, apply: false }, writerDeps);
|
|
240
|
+
const before = jsonAt(join(facts.root, SETTINGS_FILE));
|
|
241
|
+
addFile(SETTINGS_FILE, before, mergeAutonomySettings(before, render));
|
|
242
|
+
} else if (HOOK_VARIANTS.includes(variant)) {
|
|
243
|
+
const plan = writeGateHook({ cwd: facts.root, dryRun: true }, writerDeps);
|
|
244
|
+
if (plan.wirePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeHookEntry(plan.projectSettings.data));
|
|
245
|
+
if (plan.placePlanned) writes.push({ path: join(facts.root, HOOK_FILE_REL), bytes: plan.bundleContent });
|
|
246
|
+
} else if (MCP_VARIANTS.includes(variant)) {
|
|
247
|
+
const result = writeMcp({ cwd: facts.root, dryRun: true, replace: variant === 'mcp-channel.differing' }, writerDeps);
|
|
248
|
+
const { plan, registration } = result;
|
|
249
|
+
if (plan.writeMcpJson) addFile(MCP_JSON_REL, registration.mcpJson.data, JSON.parse(plan.mcpBody));
|
|
250
|
+
if (plan.writeSettings) addFile(SETTINGS_FILE, registration.settings.data, JSON.parse(plan.settingsBody));
|
|
251
|
+
} else if (AGENT_VARIANTS.includes(variant)) {
|
|
252
|
+
const plan = writeCheapAgents({ cwd: facts.root, dryRun: true }, writerDeps);
|
|
253
|
+
for (const item of plan.plan.filter(({ action }) => ['place', 're-derive'].includes(action))) {
|
|
254
|
+
writes.push({ path: item.abs, bytes: item.content });
|
|
255
|
+
}
|
|
256
|
+
} else if (SKILL_VARIANTS.includes(variant)) {
|
|
257
|
+
const plan = planFor({ ...writerDeps, lstat: lstatSync, readdir: readdirSync, readFile: readFileSync });
|
|
258
|
+
for (const target of plan.targets.filter(({ action }) => ['install', 'replace'].includes(action))) {
|
|
259
|
+
const stem = basename(target.path);
|
|
260
|
+
const bytes = Object.keys(SKILL_FILES).sort().map((name) => [name,
|
|
261
|
+
readFileSync(join(facts.toolsDir, '..', 'references', 'vendor', stem, SKILL_FILES[name]))]);
|
|
262
|
+
writes.push({ path: target.path, bytes: Buffer.concat(bytes.flatMap(([name, body]) => [Buffer.from(`${name}\0`), body])), directory: true });
|
|
263
|
+
temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-tmp-*`);
|
|
264
|
+
if (target.action === 'replace') temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-old-*`);
|
|
265
|
+
}
|
|
266
|
+
} else throw new Error(`unsupported variant: ${variant}`);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
throw error.code === 'CHECK_HALTED' ? error : halted([error.message]);
|
|
269
|
+
}
|
|
270
|
+
const directories = new Set();
|
|
271
|
+
const changedFiles = files.filter(({ before, after }) => diffFiles(before, after).length);
|
|
272
|
+
for (const path of [...changedFiles.map(({ path }) => join(facts.root, path)), ...writes.map(({ path }) => path)]) {
|
|
273
|
+
for (let parent = dirname(path); !existsSync(parent); parent = dirname(parent)) directories.add(parent);
|
|
274
|
+
}
|
|
275
|
+
return { files, writes, temporaries, directories: [...directories] };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export const checkApply = (variant, facts, deps = {}) => {
|
|
279
|
+
const plan = plannedChange(variant, facts, deps);
|
|
280
|
+
const scope = scopeOf(variant, facts, deps);
|
|
281
|
+
const { entries, refused } = judgeChange({ variant, files: plan.files, scope });
|
|
282
|
+
const lines = [];
|
|
283
|
+
const display = (path) => facts.root && covers(facts.root, path) ? relative(facts.root, path) : path;
|
|
284
|
+
for (const file of plan.files) {
|
|
285
|
+
const changes = entries.filter(({ path }) => path === file.path);
|
|
286
|
+
if (!changes.length) continue;
|
|
287
|
+
lines.push(`${file.path}:`);
|
|
288
|
+
for (const entry of changes) {
|
|
289
|
+
const old = entry.change === 'altered' ? `${textValue(entry.previous)} → ` : '';
|
|
290
|
+
lines.push(` ${entry.key}: ${entry.change} ${old}${textValue(entry.value)} — ${entry.class ?? 'unknown'}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const pathLine = (path, directory, line) => {
|
|
294
|
+
lines.push(line);
|
|
295
|
+
if (!scope.admitsPath(path, directory)) refused.push({ path: display(path), key: display(path),
|
|
296
|
+
reason: `beyond the declared scope of ${variant}` });
|
|
297
|
+
};
|
|
298
|
+
for (const write of plan.writes) pathLine(write.path, false, `writes: ${display(write.path)} sha256:${sha(write.bytes)}`);
|
|
299
|
+
for (const path of plan.directories) pathLine(path, true, `writes: ${display(path)}/ created`);
|
|
300
|
+
lines.push(...plan.temporaries);
|
|
301
|
+
if (refused.length) {
|
|
302
|
+
for (const entry of refused) lines.push(`refused: ${entry.path} ${entry.key} — ${entry.reason}`);
|
|
303
|
+
lines.push('nothing written');
|
|
304
|
+
} else lines.push(lines.length ? `scope: within the declared scope of ${variant}` : `nothing to change: ${variant} is already applied`);
|
|
305
|
+
const digest = sha(lines.join('\n'));
|
|
306
|
+
return { lines: [...lines, `digest: ${digest}`], digest, refused };
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const USAGE = 'usage: apply-danger-check --variant <v> --cwd <root> [--claude-dir <abs>] [--apply --expect <digest>]';
|
|
310
|
+
const parse = (argv, platform) => {
|
|
311
|
+
if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
|
|
312
|
+
const args = {};
|
|
313
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
314
|
+
const flag = argv[index];
|
|
315
|
+
if (flag === '--apply') args.apply = true;
|
|
316
|
+
else if (['--variant', '--cwd', '--claude-dir', '--expect'].includes(flag)) {
|
|
317
|
+
const value = argv[index + 1];
|
|
318
|
+
if (!value || value.startsWith('-')) throw new Error(`${flag} takes a value`);
|
|
319
|
+
args[flag.slice(2)] = value;
|
|
320
|
+
index += 1;
|
|
321
|
+
} else throw new Error(`unknown argument: ${flag}`);
|
|
322
|
+
}
|
|
323
|
+
if (!VARIANTS.includes(args.variant) || !args.cwd) throw new Error('--variant and --cwd are required for a harness variant');
|
|
324
|
+
if (args['claude-dir'] && (!SKILL_VARIANTS.includes(args.variant)
|
|
325
|
+
|| !(platform === 'win32' ? win32.isAbsolute : isAbsolute)(args['claude-dir']))) throw new Error('--claude-dir requires a skill variant and an absolute path');
|
|
326
|
+
if (Boolean(args.apply) !== Boolean(args.expect) || (args.expect && !/^[0-9a-f]{64}$/.test(args.expect))) {
|
|
327
|
+
throw new Error('--apply requires --expect with 64 lowercase hex characters');
|
|
328
|
+
}
|
|
329
|
+
args.root = resolve(args.cwd);
|
|
330
|
+
if (!statSync(args.root).isDirectory()) throw new Error('--cwd must be a directory');
|
|
331
|
+
return args;
|
|
332
|
+
};
|
|
333
|
+
export const main = (argv, io = {}) => {
|
|
334
|
+
const resolved = { log: console.log, error: console.error, env: process.env, home: homedir(),
|
|
335
|
+
platform: process.platform, toolsDir: TOOLS,
|
|
336
|
+
spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
|
|
337
|
+
let args;
|
|
338
|
+
try {
|
|
339
|
+
args = parse(argv, resolved.platform);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
resolved.error(error.message);
|
|
342
|
+
return 2;
|
|
343
|
+
}
|
|
344
|
+
if (args.help) {
|
|
345
|
+
resolved.log(USAGE);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
const facts = { toolsDir: resolved.toolsDir, root: args.root, env: resolved.env, home: resolved.home,
|
|
349
|
+
platform: resolved.platform, claudeDir: args['claude-dir'] };
|
|
350
|
+
let result;
|
|
351
|
+
try {
|
|
352
|
+
result = checkApply(args.variant, facts, { detect: resolved.detect });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
for (const line of error.lines ?? [error.message]) resolved.log(line);
|
|
355
|
+
return 3;
|
|
356
|
+
}
|
|
357
|
+
for (const line of result.lines) resolved.log(line);
|
|
358
|
+
if (result.refused.length) return 1;
|
|
359
|
+
if (!args.apply) return 0;
|
|
360
|
+
if (args.expect !== result.digest) return 5;
|
|
361
|
+
const spawnFacts = facts.claudeDir ? { ...facts, env: { ...facts.env, CLAUDE_CONFIG_DIR: facts.claudeDir } } : facts;
|
|
362
|
+
const child = resolved.spawn(consoleArgv(args.variant, spawnFacts).apply,
|
|
363
|
+
{ cwd: facts.root, env: resolved.env, stdio: 'inherit' });
|
|
364
|
+
if (child.status === 0) return 0;
|
|
365
|
+
resolved.error(`apply exited ${child.status}`);
|
|
366
|
+
return 6;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
|
@@ -1,23 +1,91 @@
|
|
|
1
|
-
// bridge-sandbox-recipe.mjs — the one reader of the bundled bridges' declared sandbox surfaces
|
|
2
|
-
// (contract: bridges/velocity-profile). The
|
|
3
|
-
//
|
|
1
|
+
// bridge-sandbox-recipe.mjs — the one reader of the bundled bridges' declared sandbox surfaces and
|
|
2
|
+
// the one derivation of the bridge tier (contract: bridges/velocity-profile, revision 4). The tier
|
|
3
|
+
// writer, the advisor's delta, the danger check and the autonomy preview read the same
|
|
4
|
+
// `bundledSandboxRecipe(usedBridges(...))`, so the four cannot drift.
|
|
4
5
|
//
|
|
5
6
|
// Dependency-free, Node >= 22. No side effects on import.
|
|
6
7
|
|
|
7
8
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
9
11
|
import { DEFAULT_BUNDLE_ROOT } from './bridge-settings-read.mjs';
|
|
12
|
+
import { ACTIVITIES, BACKEND_PRIORITY, REVIEW_CMD_ALIASES } from './carriers.mjs';
|
|
13
|
+
import { planRecipe, resolveActivityRecipe } from './recipes.mjs';
|
|
14
|
+
import { isReadyMember } from './review-roster-resolve.mjs';
|
|
15
|
+
import { isSeedablePathToken } from './repo-lex.mjs';
|
|
10
16
|
|
|
11
|
-
// The
|
|
12
|
-
// wrapper
|
|
13
|
-
//
|
|
14
|
-
export const
|
|
17
|
+
// The review wrappers, spelled bare as invoked. Only their `code` mode is auto-allowed: a bare
|
|
18
|
+
// `Bash(<wrapper>:*)` prefix would also cover the plan/diff file-argument modes, whose targets can
|
|
19
|
+
// point outside the repo, so those keep their prompt.
|
|
20
|
+
export const BRIDGE_REVIEW_WRAPPERS = Object.freeze(['codex-review', 'agy-review']);
|
|
21
|
+
export const BRIDGE_REVIEW_MODE = 'code';
|
|
22
|
+
// The grounding pre-step (agy's facts assembler), seeded in the EXACT quoted byte-form the
|
|
23
|
+
// procedures advisor renders, so seeded and rendered stay byte-equal.
|
|
24
|
+
export const KIT_GROUNDING_TOOL = 'tools/grounding.mjs';
|
|
25
|
+
const KIT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const GROUNDING_WRAPPER = 'agy-review';
|
|
27
|
+
// An exclusion is Bash rule syntax: a bare name matches only the argument-free command, so the
|
|
28
|
+
// tier writes `<wrapper> *`, which matches it with arguments.
|
|
29
|
+
export const exclusionOf = (wrapper) => `${wrapper} *`;
|
|
30
|
+
|
|
31
|
+
const ROLE_ORDER = Object.freeze(['review', 'execute']);
|
|
32
|
+
const SLOT_ROLE = Object.freeze({ review: 'review', execute: 'execute' });
|
|
33
|
+
|
|
34
|
+
// The bridges the project's recipes USE: every review slot and every execute-type slot of every
|
|
35
|
+
// activity, resolved to its EFFECTIVE recipe over the config and the readiness (a degraded recipe
|
|
36
|
+
// names what it degrades to; an absent config is the computed default). A ready provider has its
|
|
37
|
+
// wrapper on PATH, so used implies placed; a bridge no slot uses is absent however placed.
|
|
38
|
+
export const usedBridges = (config, readiness) => {
|
|
39
|
+
const roles = new Map();
|
|
40
|
+
const take = (bridge, role) => {
|
|
41
|
+
if (!BACKEND_PRIORITY.includes(bridge)) return;
|
|
42
|
+
if (!roles.has(bridge)) roles.set(bridge, new Set());
|
|
43
|
+
roles.get(bridge).add(role);
|
|
44
|
+
};
|
|
45
|
+
for (const [activity, def] of Object.entries(ACTIVITIES)) {
|
|
46
|
+
for (const [slot, type] of Object.entries(def.slots)) {
|
|
47
|
+
const role = SLOT_ROLE[type];
|
|
48
|
+
if (role === undefined) continue;
|
|
49
|
+
const resolved = resolveActivityRecipe({ config: config ?? {}, readiness, activity, slot });
|
|
50
|
+
if (resolved.roster) {
|
|
51
|
+
for (const row of resolved.roster) {
|
|
52
|
+
if (row.kind === 'bridge' && isReadyMember(row)) take(REVIEW_CMD_ALIASES[row.member].backend, role);
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const step of planRecipe(resolved.recipe, readiness).dispatch) take(step.backend, role);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...roles.keys()].sort().map((bridge) => ({
|
|
60
|
+
bridge,
|
|
61
|
+
roles: ROLE_ORDER.filter((role) => roles.get(bridge).has(role)),
|
|
62
|
+
}));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// An array of review-wrapper NAMES (the sandbox-lane call) reads as review-role entries.
|
|
66
|
+
const rolesFor = (used, manifest) => {
|
|
67
|
+
if (used.every((entry) => typeof entry === 'string')) {
|
|
68
|
+
return used.includes(manifest?.roles?.review?.cmd) ? ['review'] : [];
|
|
69
|
+
}
|
|
70
|
+
return used.find((entry) => entry?.bridge === manifest?.name)?.roles ?? [];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const pushOnce = (list, value) => {
|
|
74
|
+
if (!list.includes(value)) list.push(value);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// The surfaces of the used bridges over the bundled manifests, in manifest-name order, each list in
|
|
78
|
+
// its own order, a value already taken skipped.
|
|
79
|
+
export const bundledSandboxRecipe = (used, deps = {}) => {
|
|
15
80
|
const readFile = deps.readFile ?? readFileSync;
|
|
16
81
|
const readDir = deps.readdir ?? readdirSync;
|
|
17
82
|
const bundleRoot = deps.bundleRoot ?? DEFAULT_BUNDLE_ROOT;
|
|
83
|
+
const allow = [];
|
|
84
|
+
const excludedCommands = [];
|
|
18
85
|
const hosts = [];
|
|
19
86
|
const dirEntries = [];
|
|
20
|
-
|
|
87
|
+
const skips = [];
|
|
88
|
+
for (const dir of [...readDir(bundleRoot)].sort()) {
|
|
21
89
|
// An unreadable/unparsable bundled manifest must NOT thin the recipe silently — a partial
|
|
22
90
|
// recipe rendered as complete is worse than none. The throw reaches the caller: a stated skip
|
|
23
91
|
// in the advisor, a STOP with zero writes in the bridge tier.
|
|
@@ -29,12 +97,31 @@ export const bundledSandboxRecipe = (placedWrappers, deps) => {
|
|
|
29
97
|
if (err?.code === 'ENOTDIR') continue;
|
|
30
98
|
throw new Error(`bundled manifest unreadable: ${join(dir, 'capability.json')} — ${err?.message ?? err}`);
|
|
31
99
|
}
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
100
|
+
const roles = rolesFor(used, manifest);
|
|
101
|
+
if (roles.length === 0) continue;
|
|
102
|
+
for (const role of roles) {
|
|
103
|
+
const cmd = manifest?.roles?.[role]?.cmd;
|
|
104
|
+
if (!cmd) continue;
|
|
105
|
+
pushOnce(excludedCommands, exclusionOf(cmd));
|
|
106
|
+
if (role !== 'review') continue;
|
|
107
|
+
pushOnce(allow, `Bash(${cmd} ${BRIDGE_REVIEW_MODE}:*)`);
|
|
108
|
+
if (cmd === GROUNDING_WRAPPER) {
|
|
109
|
+
// groundingAbsPath binds the danger check's scope to the kit path it checks
|
|
110
|
+
// (apply-danger-check.mjs) and lets a test build an unseedable path; the writer never passes it.
|
|
111
|
+
const groundingAbs = deps.groundingAbsPath ?? join(KIT_ROOT, KIT_GROUNDING_TOOL);
|
|
112
|
+
if (isSeedablePathToken(groundingAbs)) pushOnce(allow, `Bash(node "${groundingAbs}":*)`);
|
|
113
|
+
else skips.push({
|
|
114
|
+
entry: groundingAbs,
|
|
115
|
+
reason: 'the kit path is not a POSIX absolute path free of spaces/metacharacters/quoting — the grounding pre-step rule is not seeded (its prompt stays); add a hand-picked entry if you accept the spelling',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(manifest.networkHosts)) for (const host of manifest.networkHosts) pushOnce(hosts, host);
|
|
120
|
+
if (Array.isArray(manifest.writableDirs)) {
|
|
121
|
+
for (const entry of manifest.writableDirs) {
|
|
122
|
+
if (!dirEntries.some((taken) => JSON.stringify(taken) === JSON.stringify(entry))) dirEntries.push(entry);
|
|
123
|
+
}
|
|
36
124
|
}
|
|
37
|
-
if (Array.isArray(manifest.writableDirs)) dirEntries.push(...manifest.writableDirs);
|
|
38
125
|
}
|
|
39
|
-
return { hosts, dirEntries };
|
|
126
|
+
return { allow, excludedCommands, hosts, dirEntries, skips };
|
|
40
127
|
};
|