create-agentic-workspace 0.0.0 → 0.2.2
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/README.md +46 -0
- package/bin/create-agentic-workspace.mjs +33 -0
- package/package.json +36 -4
- package/permission-floor.json +411 -0
- package/src/answers.mjs +68 -0
- package/src/argv.mjs +67 -0
- package/src/identity.mjs +155 -0
- package/src/permissionFloor.mjs +179 -0
- package/src/preview.mjs +55 -0
- package/src/questions.mjs +77 -0
- package/src/reconcile.mjs +47 -0
- package/src/run.mjs +213 -0
- package/src/scaffold.mjs +71 -0
- package/src/util.mjs +78 -0
- package/templates/CLAUDE.md.tmpl +16 -0
- package/templates/foundry-README.md +5 -0
- package/templates/foundry-project.json.tmpl +9 -0
- package/templates/gitignore.tmpl +18 -0
- package/templates/specs-features-README.md +7 -0
- package/templates/specs-lifecycle-README.md +5 -0
- package/bin.mjs +0 -7
package/src/argv.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// argv.mjs — the argv parser. Its accepted long-flag set is derived from the question table AND
|
|
2
|
+
// FROM NO SECOND LIST (AC-BCL-2). --help is rendered one line per table record.
|
|
3
|
+
import { RefusalError } from './util.mjs';
|
|
4
|
+
|
|
5
|
+
/** Parse argv against `table` (defaults to QUESTION_TABLE at call sites). Returns
|
|
6
|
+
* { values: {id: value}, provided: Set<id> }. Throws RefusalError on an unknown flag, a missing
|
|
7
|
+
* value, or an out-of-`choices` value — writing nothing is the caller's responsibility (this
|
|
8
|
+
* function performs no I/O). */
|
|
9
|
+
export function parseArgv(argv, table) {
|
|
10
|
+
const byFlag = new Map(table.map((r) => [r.flag, r]));
|
|
11
|
+
const values = {};
|
|
12
|
+
const provided = new Set();
|
|
13
|
+
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
const tok = argv[i];
|
|
16
|
+
if (!tok.startsWith('--')) {
|
|
17
|
+
throw new RefusalError(`unexpected positional argument: ${tok}`);
|
|
18
|
+
}
|
|
19
|
+
let name = tok.slice(2);
|
|
20
|
+
let inline = null;
|
|
21
|
+
const eq = name.indexOf('=');
|
|
22
|
+
if (eq !== -1) {
|
|
23
|
+
inline = name.slice(eq + 1);
|
|
24
|
+
name = name.slice(0, eq);
|
|
25
|
+
}
|
|
26
|
+
const rec = byFlag.get(name);
|
|
27
|
+
if (!rec) {
|
|
28
|
+
throw new RefusalError(`unknown flag: --${name}`, name);
|
|
29
|
+
}
|
|
30
|
+
if (rec.type === 'boolean') {
|
|
31
|
+
values[rec.id] = inline === null ? true : inline === 'true';
|
|
32
|
+
} else {
|
|
33
|
+
let v = inline;
|
|
34
|
+
if (v === null) {
|
|
35
|
+
const next = argv[i + 1];
|
|
36
|
+
if (next === undefined || (next.startsWith('--') && next.length > 2)) {
|
|
37
|
+
throw new RefusalError(`--${rec.flag} needs a value`, rec.flag);
|
|
38
|
+
}
|
|
39
|
+
v = next;
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
if (rec.choices && !rec.choices.includes(v)) {
|
|
43
|
+
throw new RefusalError(
|
|
44
|
+
`--${rec.flag} must be one of: ${rec.choices.join(', ')} (got ${JSON.stringify(v)})`,
|
|
45
|
+
rec.flag,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
values[rec.id] = v;
|
|
49
|
+
}
|
|
50
|
+
provided.add(rec.id);
|
|
51
|
+
}
|
|
52
|
+
return { values, provided };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render one `--help` line per table record — the third leg of the flag/prompt/help bijection. */
|
|
56
|
+
export function renderHelp(table, { programName = 'create-agentic-workspace' } = {}) {
|
|
57
|
+
const lines = [`Usage: ${programName} [options]`, ''];
|
|
58
|
+
for (const rec of table) {
|
|
59
|
+
const flagCol = rec.type === 'boolean' ? `--${rec.flag}` : `--${rec.flag} <value>`;
|
|
60
|
+
const req = rec.required ? ' (required)' : rec.default !== undefined && rec.default !== '' ? ` (default: ${rec.default})` : '';
|
|
61
|
+
const choices = rec.choices ? ` [choices: ${rec.choices.join('|')}]` : '';
|
|
62
|
+
lines.push(` ${flagCol.padEnd(24)} ${rec.prompt}${choices}${req}`);
|
|
63
|
+
}
|
|
64
|
+
lines.push('');
|
|
65
|
+
lines.push('This CLI collects and transmits nothing: no telemetry.');
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// identity.mjs — the out-of-session identity half (AC-BCL-7), proved differentially equal to
|
|
2
|
+
// `scripts/foundry-bootstrap.sh`. Every git-config write goes through `git config` in an argv
|
|
3
|
+
// position — never composed/templated text — so git's own writer, quoting and locking apply, and
|
|
4
|
+
// the CLI's output is byte-identical to the shell script's for the same inputs.
|
|
5
|
+
import { execFileSync, execFile as execFileCb } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { RefusalError, physicalResolve } from './util.mjs';
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFileCb);
|
|
13
|
+
|
|
14
|
+
const SLUG_RE = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
|
|
16
|
+
/** Validate a --gh-account value against the closed charset, refusing '.'/'..' (AC-BCL-7). */
|
|
17
|
+
export function validateSlug(raw) {
|
|
18
|
+
if (raw === '.' || raw === '..' || !SLUG_RE.test(raw)) {
|
|
19
|
+
throw new RefusalError(
|
|
20
|
+
`invalid --gh-account slug (need ^[A-Za-z0-9._-]+$, not '.'/'..'): ${raw}`,
|
|
21
|
+
'gh-account',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STRIPPED_ENV_VARS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN', 'GH_HOST'];
|
|
28
|
+
|
|
29
|
+
/** At most ONE `gh api user` invocation, timeout-bounded, jailed under its own GH_CONFIG_DIR, with
|
|
30
|
+
* the five higher-precedence token/host variables stripped from the child environment. Compares
|
|
31
|
+
* the returned `.login` against the declared slug (ASCII case-insensitive) and discards the probe
|
|
32
|
+
* WHOLE on any mismatch, parse failure, timeout, or absent `gh` — never a partial adopt. No probe
|
|
33
|
+
* output (in whole or in part) is ever written to a file, logged, or printed by this function. */
|
|
34
|
+
export async function probeGhIdentity(slug, { homeDir, timeoutMs = 5000, env = process.env } = {}) {
|
|
35
|
+
// A STRING LITERAL, never an env-supplied name (PR #61 security review Risk 1). This previously
|
|
36
|
+
// read `env.FOUNDRY_TEST_GH_BIN || 'gh'`, which made the spec's "the child-process set is closed
|
|
37
|
+
// to {git, gh}, so `claude` is never spawned" literally false: anything able to set that variable
|
|
38
|
+
// — a direnv .envrc in a freshly cloned repo, an exported var, CI — chose the binary this line
|
|
39
|
+
// spawns, `claude` included. It was also DEAD: the only reference in the tree was its own
|
|
40
|
+
// definition, since the test suites inject their `gh` stub via PATH. Removed rather than guarded;
|
|
41
|
+
// an unused escape hatch is all cost.
|
|
42
|
+
const gh = 'gh';
|
|
43
|
+
const jail = path.join(homeDir, '.config', `gh-${slug}`);
|
|
44
|
+
fs.mkdirSync(jail, { recursive: true });
|
|
45
|
+
const childEnv = { ...env, GH_CONFIG_DIR: jail };
|
|
46
|
+
for (const v of STRIPPED_ENV_VARS) delete childEnv[v];
|
|
47
|
+
|
|
48
|
+
let stdout;
|
|
49
|
+
try {
|
|
50
|
+
const result = await execFileAsync(gh, ['api', 'user'], { env: childEnv, timeout: timeoutMs });
|
|
51
|
+
stdout = result.stdout;
|
|
52
|
+
} catch {
|
|
53
|
+
return null; // absent gh, non-zero exit, or timeout — degrade, no further network call
|
|
54
|
+
}
|
|
55
|
+
let data;
|
|
56
|
+
try {
|
|
57
|
+
data = JSON.parse(stdout);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const login = typeof data.login === 'string' ? data.login : '';
|
|
62
|
+
if (login.toLowerCase() !== slug.toLowerCase()) return null; // whole discard, never partial
|
|
63
|
+
const name = typeof data.name === 'string' ? data.name : '';
|
|
64
|
+
const email = typeof data.email === 'string' ? data.email : '';
|
|
65
|
+
if (!name || !email) return null;
|
|
66
|
+
return { name, email };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseGitAuthor(raw) {
|
|
70
|
+
const m = /^(.*) <([^<>]+)>$/.exec(raw);
|
|
71
|
+
if (!m) {
|
|
72
|
+
throw new RefusalError(`malformed --git-author (expected 'Name <email>'): ${raw}`, 'git-author');
|
|
73
|
+
}
|
|
74
|
+
return { name: m[1], email: m[2] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve the declared "Name <email>" (AC-BCL-7 order): (1) --git-author, (2) the bounded gh
|
|
78
|
+
* probe, (3) an interactive TTY prompt, (4) fail closed. */
|
|
79
|
+
export async function resolveIdentity(slug, { gitAuthor, homeDir, isTTY, promptFn } = {}) {
|
|
80
|
+
if (gitAuthor) {
|
|
81
|
+
return parseGitAuthor(gitAuthor);
|
|
82
|
+
}
|
|
83
|
+
const probed = await probeGhIdentity(slug, { homeDir });
|
|
84
|
+
if (probed) return probed;
|
|
85
|
+
if (isTTY && promptFn) {
|
|
86
|
+
const name = (await promptFn(`commit-identity name for account ${slug}: `)).trim();
|
|
87
|
+
const email = (await promptFn(`commit-identity email for account ${slug}: `)).trim();
|
|
88
|
+
if (name && email) return { name, email };
|
|
89
|
+
}
|
|
90
|
+
throw new RefusalError(
|
|
91
|
+
`could not resolve a commit identity for '${slug}' (give --git-author 'Name <email>', authenticate gh, or run interactively)`,
|
|
92
|
+
'git-author',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function validateIdentityValue(kind, val) {
|
|
97
|
+
if (!val) throw new RefusalError(`commit-identity ${kind} is empty (refusing to write a fabricated identity)`);
|
|
98
|
+
if (val.includes('\n')) throw new RefusalError(`commit-identity ${kind} is not single-line (refusing to write)`);
|
|
99
|
+
if (kind === 'email' && !val.includes('@')) {
|
|
100
|
+
throw new RefusalError(`commit-identity email lacks '@': ${val} (refusing to write)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Physically-resolved target root with a trailing separator (the <canon> Terminology term). Works
|
|
105
|
+
* even when the target does not yet exist (preview time), by resolving the longest existing
|
|
106
|
+
* ancestor and appending the missing segments (see util.physicalResolve). */
|
|
107
|
+
export function canonicalTargetWithSlash(targetRoot) {
|
|
108
|
+
const real = physicalResolve(targetRoot);
|
|
109
|
+
return real.endsWith(path.sep) ? real : real + path.sep;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Describe (never execute) the three machine-scope writes wireIdentity would make — used by the
|
|
113
|
+
* preview/dry-run path (AC-BCL-3), which SHALL spawn zero child processes. */
|
|
114
|
+
export function plannedMachineScopeWrites({ slug, targetRoot, homeDir }) {
|
|
115
|
+
const canon = canonicalTargetWithSlash(targetRoot);
|
|
116
|
+
const matchPrefix = os.platform() === 'darwin' ? 'gitdir/i:' : 'gitdir:';
|
|
117
|
+
const inc = path.join(homeDir, '.config', 'git', `identity-${slug}`);
|
|
118
|
+
return [
|
|
119
|
+
{ scope: 'global git config (machine-wide)', description: `includeIf.${matchPrefix}${canon}.git.path -> ${inc}` },
|
|
120
|
+
{ scope: 'global git config (machine-wide)', description: `includeIf.${matchPrefix}${canon}.git/.path -> ${inc}` },
|
|
121
|
+
{ scope: 'per-account include file (machine-wide)', description: inc },
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Wire the three machine-scope artifacts + the repo-local useConfigOnly + the .claude/gh-identity
|
|
126
|
+
* marker, EXACTLY as `scripts/foundry-bootstrap.sh`'s seed_commit_identity/seed_gh_identity do:
|
|
127
|
+
* every git-config write goes through `git config` in an argv position (AC-BCL-7). Returns the
|
|
128
|
+
* paths written, for the caller's machine-scope-write accounting (AC-BCL-9). */
|
|
129
|
+
export function wireIdentity({ slug, name, email, targetRoot, homeDir }) {
|
|
130
|
+
validateIdentityValue('name', name);
|
|
131
|
+
validateIdentityValue('email', email);
|
|
132
|
+
|
|
133
|
+
const inc = path.join(homeDir, '.config', 'git', `identity-${slug}`);
|
|
134
|
+
fs.mkdirSync(path.dirname(inc), { recursive: true });
|
|
135
|
+
execFileSync('git', ['config', '--file', inc, 'user.name', name]);
|
|
136
|
+
execFileSync('git', ['config', '--file', inc, 'user.email', email]);
|
|
137
|
+
|
|
138
|
+
const canon = canonicalTargetWithSlash(targetRoot);
|
|
139
|
+
const matchPrefix = os.platform() === 'darwin' ? 'gitdir/i:' : 'gitdir:';
|
|
140
|
+
const narrow1 = `${matchPrefix}${canon}.git`;
|
|
141
|
+
const narrow2 = `${matchPrefix}${canon}.git/`;
|
|
142
|
+
const gitConfigEnv = { ...process.env, HOME: homeDir };
|
|
143
|
+
execFileSync('git', ['config', '--global', `includeIf.${narrow1}.path`, inc], { env: gitConfigEnv });
|
|
144
|
+
execFileSync('git', ['config', '--global', `includeIf.${narrow2}.path`, inc], { env: gitConfigEnv });
|
|
145
|
+
|
|
146
|
+
const dotGitConfig = path.join(targetRoot, '.git', 'config');
|
|
147
|
+
execFileSync('git', ['config', '--file', dotGitConfig, 'user.useConfigOnly', 'true']);
|
|
148
|
+
|
|
149
|
+
const claudeDir = path.join(targetRoot, '.claude');
|
|
150
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
151
|
+
const marker = path.join(claudeDir, 'gh-identity');
|
|
152
|
+
fs.writeFileSync(marker, `${slug}\n`);
|
|
153
|
+
|
|
154
|
+
return { includeFile: inc, narrow1, narrow2, dotGitConfig, marker };
|
|
155
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// permissionFloor.mjs — loads the bundled map, builds the written settings.json object (a
|
|
2
|
+
// verbatim declaration, never a grant — AC-BCL-4), renders the capability preview, and classifies
|
|
3
|
+
// drift between the map and a target's effective settings using the AC-DPF-8 vocabulary
|
|
4
|
+
// (AC-BCL-8). `covers()` agrees with tests/test_permission_floor_map.py::_subsumes on the shared
|
|
5
|
+
// 8-row table by construction (same prefix-subsumption rule).
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
|
|
8
|
+
export function loadMap(mapPath) {
|
|
9
|
+
const text = fs.readFileSync(mapPath, 'utf-8');
|
|
10
|
+
return JSON.parse(text);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RULE_RE = /^([A-Za-z0-9_-]+)\((.*)\)$/s;
|
|
14
|
+
|
|
15
|
+
export function ruleBody(rule) {
|
|
16
|
+
const m = RULE_RE.exec(rule);
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
return m[2];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ruleTool(rule) {
|
|
22
|
+
const m = RULE_RE.exec(rule);
|
|
23
|
+
return m ? m[1] : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** covers(A, B): true if the broad rule A's reach (a `:*`-terminated prefix) subsumes rule B's
|
|
27
|
+
* body. Mirrors tests/test_permission_floor_map.py::_subsumes exactly (same prefix rule). */
|
|
28
|
+
export function covers(ruleA, ruleB) {
|
|
29
|
+
const bodyA = ruleBody(ruleA);
|
|
30
|
+
if (bodyA === null || !bodyA.endsWith(':*')) return false;
|
|
31
|
+
const sA = bodyA.slice(0, -2);
|
|
32
|
+
const bodyB = ruleBody(ruleB);
|
|
33
|
+
if (bodyB === null) return false;
|
|
34
|
+
const sB = bodyB.endsWith(':*') ? bodyB.slice(0, -2) : bodyB;
|
|
35
|
+
return sB.startsWith(sA);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const BLANKET_BODIES = new Set(['*', 'python3 *', 'python3:*']);
|
|
39
|
+
|
|
40
|
+
/** A blanket effective allow rule: one whose reach swallows the whole map (AC-DPF-3(a)'s named
|
|
41
|
+
* spellings this atom's own controls (g)(h)(i) exercise: `Bash(*)`, `Bash(python3 *)`,
|
|
42
|
+
* `Bash(python3:*)`). */
|
|
43
|
+
export function isBlanketAllow(rule) {
|
|
44
|
+
const body = ruleBody(rule);
|
|
45
|
+
if (body === null) return false;
|
|
46
|
+
const stripped = body.endsWith(':*') ? body.slice(0, -2) : body;
|
|
47
|
+
return BLANKET_BODIES.has(body) || stripped === '' || stripped === '*';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SCRIPTS_BASENAME_RE = /scripts\/([A-Za-z0-9_.-]+)/;
|
|
51
|
+
|
|
52
|
+
/** A "ceremony" map entry (AC-DPF-8 rank 2): tier `ask` and its rule body names a scripts/
|
|
53
|
+
* basename — derived structurally, no second source of truth. */
|
|
54
|
+
export function isCeremonyEntry(entry) {
|
|
55
|
+
return entry.tier === 'ask' && SCRIPTS_BASENAME_RE.test(ruleBody(entry.rule) || '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DRIFT_CLASSES = Object.freeze([
|
|
59
|
+
'blanket-allow',
|
|
60
|
+
'ask-shadowed-ceremony',
|
|
61
|
+
'ask-shadowed',
|
|
62
|
+
'deny-missing',
|
|
63
|
+
'settings-unreadable',
|
|
64
|
+
'stale-plugin-path',
|
|
65
|
+
'allow-absent',
|
|
66
|
+
'unclassified',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** Build the settings.json object the CLI writes, verbatim from the bundled map plus the
|
|
70
|
+
* marketplace/plugin pins (AC-BCL-4). */
|
|
71
|
+
export function buildSettings(map, pins) {
|
|
72
|
+
const byTier = { allow: [], ask: [], deny: [] };
|
|
73
|
+
for (const e of map.entries) {
|
|
74
|
+
byTier[e.tier].push(e.rule);
|
|
75
|
+
}
|
|
76
|
+
// THE PINNED LITERAL (AC-BCL-4(b), contract v1.2 — PR #61 security review Block 1). `ref` is
|
|
77
|
+
// load-bearing, not decoration: without it the adopter's FIRST marketplace resolution floats to
|
|
78
|
+
// whatever the default branch points at, and because the floor's allow rules are
|
|
79
|
+
// version-wildcarded (`cache/*/foundry/*/scripts/...`), the operator's single trust acceptance
|
|
80
|
+
// becomes a standing grant over whatever code that resolution delivered. That is the same
|
|
81
|
+
// floating-pin defect `autoUpdate: false` guards the LATER fetches against, left open on the
|
|
82
|
+
// first one — and strictly weaker than the manual path docs/QUICKSTART.md documents
|
|
83
|
+
// (the documented `marketplace add …#<tag>` install line). Single-sourced from
|
|
84
|
+
// the `foundry` pin block, so it cannot drift from the marketplace manifest.
|
|
85
|
+
const marketplaceEntry = {
|
|
86
|
+
source: { source: 'github', repo: pins.marketplace_repo, ref: `v${pins.plugin_version}` },
|
|
87
|
+
autoUpdate: false,
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
permissions: { allow: byTier.allow, ask: byTier.ask, deny: byTier.deny },
|
|
91
|
+
extraKnownMarketplaces: { [pins.marketplace_name]: marketplaceEntry },
|
|
92
|
+
enabledPlugins: { [`${pins.plugin_name}@${pins.marketplace_name}`]: true },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** One capability-preview line per bundled-map entry, grouped by tier, carrying rule/tier/
|
|
97
|
+
* rationale (AC-BCL-3). */
|
|
98
|
+
export function renderCapabilityLines(map) {
|
|
99
|
+
const lines = [];
|
|
100
|
+
for (const tier of ['allow', 'ask', 'deny']) {
|
|
101
|
+
const entries = map.entries.filter((e) => e.tier === tier);
|
|
102
|
+
if (entries.length === 0) continue;
|
|
103
|
+
lines.push(` [${tier}] (${entries.length} rules)`);
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
lines.push(` - ${e.rule} — ${e.rationale}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Classify drift between the bundled map and a target's effective permission configuration.
|
|
112
|
+
* `effective` is { allow: [{rule, origin, tierKey}], ask: [...], deny: [...] } drawn from
|
|
113
|
+
* settings.json unioned with settings.local.json (origin-tracked, AC-BCL-8). `readable` names
|
|
114
|
+
* whether each origin file that exists parsed successfully. `pluginRootExpansion` is the list of
|
|
115
|
+
* directories `plugin_root_glob` expanded to on disk (empty => stale-plugin-path). Returns an
|
|
116
|
+
* array of finding objects `{class, ...}` using exactly the AC-DPF-8 vocabulary — no other class
|
|
117
|
+
* name is ever emitted. */
|
|
118
|
+
export function classifyDrift(map, effective, { pluginRootExpansion = [], unreadableOrigins = [] } = {}) {
|
|
119
|
+
const findings = [];
|
|
120
|
+
|
|
121
|
+
for (const origin of unreadableOrigins) {
|
|
122
|
+
findings.push({ class: 'settings-unreadable', origin });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const shadowedByBlanket = new Set();
|
|
126
|
+
for (const a of effective.allow) {
|
|
127
|
+
if (isBlanketAllow(a.rule)) {
|
|
128
|
+
const swallowed = map.entries.filter((e) => e.tier !== 'allow');
|
|
129
|
+
findings.push({
|
|
130
|
+
class: 'blanket-allow',
|
|
131
|
+
rule: a.rule,
|
|
132
|
+
origin: a.origin,
|
|
133
|
+
swallows: swallowed.map((e) => e.rule),
|
|
134
|
+
});
|
|
135
|
+
for (const e of swallowed) shadowedByBlanket.add(e.rule);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const entry of map.entries.filter((e) => e.tier === 'ask')) {
|
|
140
|
+
if (shadowedByBlanket.has(entry.rule)) continue;
|
|
141
|
+
const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule));
|
|
142
|
+
if (coveringAllow.length > 0) {
|
|
143
|
+
findings.push({
|
|
144
|
+
class: isCeremonyEntry(entry) ? 'ask-shadowed-ceremony' : 'ask-shadowed',
|
|
145
|
+
rule: entry.rule,
|
|
146
|
+
coveredBy: coveringAllow.map((a) => ({ rule: a.rule, origin: a.origin, tierKey: a.tierKey })),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const entry of map.entries.filter((e) => e.tier === 'deny')) {
|
|
152
|
+
const coveringDeny = effective.deny.filter((d) => covers(d.rule, entry.rule) || d.rule === entry.rule);
|
|
153
|
+
if (coveringDeny.length === 0) {
|
|
154
|
+
findings.push({ class: 'deny-missing', rule: entry.rule });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (pluginRootExpansion.length === 0) {
|
|
159
|
+
findings.push({ class: 'stale-plugin-path', glob: map.plugin_root_glob });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const entry of map.entries.filter((e) => e.tier === 'allow')) {
|
|
163
|
+
const coveringAllow = effective.allow.filter((a) => covers(a.rule, entry.rule) || a.rule === entry.rule);
|
|
164
|
+
if (coveringAllow.length === 0) {
|
|
165
|
+
findings.push({ class: 'allow-absent', rule: entry.rule });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const tierKey of ['allow', 'ask', 'deny']) {
|
|
170
|
+
for (const eff of effective[tierKey]) {
|
|
171
|
+
if (ruleBody(eff.rule) === null) {
|
|
172
|
+
const prefix = (eff.rule.split('(')[0] || '?').replace(/[^A-Za-z0-9_-]/g, '') || '?';
|
|
173
|
+
findings.push({ class: 'unclassified', toolPrefix: prefix.slice(0, 32), origin: eff.origin, tierKey });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return findings;
|
|
179
|
+
}
|
package/src/preview.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// preview.mjs — the full preview (AC-BCL-3): every managed path + action, every machine-scope
|
|
2
|
+
// write, and every capability the CLI will declare, before the first byte lands.
|
|
3
|
+
import { renderCapabilityLines } from './permissionFloor.mjs';
|
|
4
|
+
|
|
5
|
+
export function renderPreview({ plan, machineScopeWrites, map }) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
lines.push('The following paths will be written or reconciled under the target root:');
|
|
8
|
+
for (const f of plan) {
|
|
9
|
+
lines.push(` [${f.action}] ${f.relPath}`);
|
|
10
|
+
}
|
|
11
|
+
lines.push('');
|
|
12
|
+
if (machineScopeWrites.length > 0) {
|
|
13
|
+
lines.push('The following machine-scope writes will be made (outside the target root):');
|
|
14
|
+
for (const w of machineScopeWrites) {
|
|
15
|
+
lines.push(` [${w.scope}] ${w.description}`);
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
lines.push('No machine-scope writes (no --gh-account given).');
|
|
19
|
+
}
|
|
20
|
+
lines.push('');
|
|
21
|
+
lines.push('The following capabilities will be declared (never granted — the workspace trust dialog decides):');
|
|
22
|
+
lines.push(...renderCapabilityLines(map));
|
|
23
|
+
return lines.join('\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `isGitRepo` is computed by the CALLER and passed in, so this module stays pure (it renders text;
|
|
27
|
+
// it does not probe the filesystem).
|
|
28
|
+
//
|
|
29
|
+
// WHY THE GIT STEP IS PRINTED AND NOT PERFORMED. A workspace that is not a git repository is a
|
|
30
|
+
// dead end: the factory's whole discipline is branch-per-atom → PR → merge floor, and the setup
|
|
31
|
+
// path tells the reader to apply branch protection a few steps later — which needs a repo AND a
|
|
32
|
+
// remote. This CLI ALREADY writes a `.gitignore`, which means nothing without one.
|
|
33
|
+
//
|
|
34
|
+
// It is still printed rather than run. This CLI's contract is that it writes exactly the files it
|
|
35
|
+
// previewed and nothing else; `git init` would be an un-previewed side effect, and the choice of
|
|
36
|
+
// remote is genuinely the reader's. `create-vite` prints its next steps the same way, for the same
|
|
37
|
+
// reason. Telling keeps the promise; doing would quietly break it.
|
|
38
|
+
export const TRUST_HANDOFF_TEXT = (dir, { isGitRepo = true } = {}) => `
|
|
39
|
+
The workspace has been written. The permission floor above is a declaration, not a grant: the
|
|
40
|
+
platform's trust dialog is the consent ceremony, and the \`allow\` rules take effect only after
|
|
41
|
+
you accept it — the dialog lists them.
|
|
42
|
+
|
|
43
|
+
cd ${dir}${isGitRepo ? '' : `
|
|
44
|
+
git init && git add -A && git commit -m 'workspace seed'
|
|
45
|
+
# ^ this directory is NOT a git repository yet. The factory works through
|
|
46
|
+
# branch-per-atom → PR → the merge floor, so it needs a repo and a remote.
|
|
47
|
+
# Create the remote too (e.g. \`gh repo create <you>/<project>-handbook --private --source=. --push\`)
|
|
48
|
+
# before you reach the branch-protection step.`}
|
|
49
|
+
claude
|
|
50
|
+
> (accept the trust dialog when prompted)
|
|
51
|
+
> /foundry:init
|
|
52
|
+
|
|
53
|
+
Once the session is open, run \`/foundry:doctor\` — it compares this written floor against the
|
|
54
|
+
installed plugin's own copy of the map, the one independent check that did not travel through npm.
|
|
55
|
+
`;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// questions.mjs — THE single question table (AC-BCL-2). Every accepted long flag, every --help
|
|
2
|
+
// line and every interactive prompt are derived from this table and from no second list.
|
|
3
|
+
//
|
|
4
|
+
// Record shape: { id, flag, type: 'string'|'boolean', prompt, default? , required?, choices?,
|
|
5
|
+
// interactive } — `default` XOR `required: true` (never both, never neither).
|
|
6
|
+
|
|
7
|
+
export const QUESTION_TABLE = [
|
|
8
|
+
{
|
|
9
|
+
id: 'dir',
|
|
10
|
+
flag: 'dir',
|
|
11
|
+
type: 'string',
|
|
12
|
+
prompt: 'Workspace directory to create',
|
|
13
|
+
required: true,
|
|
14
|
+
interactive: true,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'existing',
|
|
18
|
+
flag: 'existing',
|
|
19
|
+
type: 'boolean',
|
|
20
|
+
prompt: 'Target directory already exists and is non-empty (scaffold into it; never clobbers)',
|
|
21
|
+
default: false,
|
|
22
|
+
interactive: true,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'stageMode',
|
|
26
|
+
flag: 'stage-mode',
|
|
27
|
+
type: 'string',
|
|
28
|
+
prompt: 'Stage mode',
|
|
29
|
+
default: 'lean',
|
|
30
|
+
choices: ['lean', 'scale'],
|
|
31
|
+
interactive: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'ghAccount',
|
|
35
|
+
flag: 'gh-account',
|
|
36
|
+
type: 'string',
|
|
37
|
+
prompt: 'GitHub account slug for commit-identity isolation (blank to skip)',
|
|
38
|
+
default: '',
|
|
39
|
+
interactive: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: 'gitAuthor',
|
|
43
|
+
flag: 'git-author',
|
|
44
|
+
type: 'string',
|
|
45
|
+
prompt: 'Git author "Name <email>" (blank: try gh, then prompt, then fail closed — only used with --gh-account)',
|
|
46
|
+
default: '',
|
|
47
|
+
interactive: true,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'yes',
|
|
51
|
+
flag: 'yes',
|
|
52
|
+
type: 'boolean',
|
|
53
|
+
prompt: 'Accept every default and the write-phase confirmation without prompting',
|
|
54
|
+
default: false,
|
|
55
|
+
interactive: false,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'dryRun',
|
|
59
|
+
flag: 'dry-run',
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
prompt: 'Print the preview only; make no write and no side effect',
|
|
62
|
+
default: false,
|
|
63
|
+
interactive: false,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'help',
|
|
67
|
+
flag: 'help',
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
prompt: 'Show this help and exit',
|
|
70
|
+
default: false,
|
|
71
|
+
interactive: false,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export function tableFlags(table = QUESTION_TABLE) {
|
|
76
|
+
return table.map((r) => r.flag);
|
|
77
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// reconcile.mjs — the ONE write path: terraform-init reconcile shape, shared by a greenfield
|
|
2
|
+
// create, an --existing scaffold-into, and an idempotent re-run (AC-BCL-8/AC-BCL-9). A managed
|
|
3
|
+
// path that is absent is created; one that exists and matches is left untouched (no write
|
|
4
|
+
// syscall at all — bytes/inode/mtime survive); one that exists and differs is reported `drifted`
|
|
5
|
+
// and NEVER written, on any path, in any mode (never-clobber is unconditional).
|
|
6
|
+
import { ensureDirFor, fileBytesEqual } from './util.mjs';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
|
|
9
|
+
/** Compute the action for each managed file WITHOUT writing anything. */
|
|
10
|
+
export function planManagedFiles(managedFiles) {
|
|
11
|
+
return managedFiles.map((f) => {
|
|
12
|
+
const { present, equal, notRegular } = fileBytesEqual(f.absPath, f.bytes);
|
|
13
|
+
let action;
|
|
14
|
+
if (!present) action = 'create';
|
|
15
|
+
else if (notRegular) action = 'drifted';
|
|
16
|
+
else action = equal ? 'unchanged' : 'drifted';
|
|
17
|
+
return { ...f, action };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Apply a plan: writes ONLY the `create` rows. `unchanged`/`drifted` rows are never touched —
|
|
22
|
+
* this is what makes never-clobber unconditional and re-runs byte/inode/mtime-stable. */
|
|
23
|
+
export function applyPlan(plan) {
|
|
24
|
+
for (const f of plan) {
|
|
25
|
+
if (f.action !== 'create') continue;
|
|
26
|
+
ensureDirFor(f.absPath);
|
|
27
|
+
// O_EXCL ('wx'), not a plain write (PR #61 security review Block 2, defence in depth). The
|
|
28
|
+
// plan already refuses to emit `create` for any path that exists — including a dangling
|
|
29
|
+
// symlink, since fileBytesEqual lstats rather than existsSync's. 'wx' makes the WRITE itself
|
|
30
|
+
// refuse a target that came into existence between plan and apply, so the never-clobber
|
|
31
|
+
// guarantee does not rest on the plan/apply window being atomic. A plain write would follow a
|
|
32
|
+
// symlink planted in that window; this one fails with EEXIST instead.
|
|
33
|
+
const fd = fs.openSync(f.absPath, 'wx');
|
|
34
|
+
try {
|
|
35
|
+
fs.writeFileSync(fd, f.bytes);
|
|
36
|
+
} finally {
|
|
37
|
+
fs.closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return plan;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The total exit-code matrix (AC-BCL-8): 0 unless >=1 row is `drifted`, in which case 2. Refusals
|
|
44
|
+
* are a distinct path (exit 1) handled by the caller before any plan is computed. */
|
|
45
|
+
export function exitCodeForPlan(plan) {
|
|
46
|
+
return plan.some((f) => f.action === 'drifted') ? 2 : 0;
|
|
47
|
+
}
|