@worca/app 0.0.1
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 +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// src/core/guardrails.mjs
|
|
2
|
+
// Per-project guardrails: the pure policy layer.
|
|
3
|
+
// - 5-key settings shape: { honorProjectSettings, envScrub, envAllowlist,
|
|
4
|
+
// protectedPaths, deny } — what enforcement consumes.
|
|
5
|
+
// - stored config shape: { level, custom } — what the DB holds. Preset levels
|
|
6
|
+
// resolve from GUARDRAIL_PRESETS at read time so preset improvements ship
|
|
7
|
+
// with worca upgrades; `custom` is the user's pinned blob (dormant unless
|
|
8
|
+
// level === 'custom', preserved across level switches).
|
|
9
|
+
// Storage: project_config.extra.guardrails (config.mjs). Enforcement: a Claude
|
|
10
|
+
// Code `permissions` object in ONE --settings payload + a scrubbed spawn env
|
|
11
|
+
// (claude-runner.mjs). Everything here is pure and throw-free except the
|
|
12
|
+
// validate* functions, which report instead of throwing.
|
|
13
|
+
//
|
|
14
|
+
// Rule-spelling invariants (see plan Global Constraints):
|
|
15
|
+
// - Bash denies are exact+prefix PAIRS: Bash(cmd) + Bash(cmd:*).
|
|
16
|
+
// - protectedPaths: slash-less patterns match at any depth; slash-containing
|
|
17
|
+
// patterns MUST carry a **/ prefix or they anchor to cwd and miss members
|
|
18
|
+
// on detached workspace runs.
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_GUARDRAILS = Object.freeze({
|
|
21
|
+
honorProjectSettings: true,
|
|
22
|
+
envScrub: false,
|
|
23
|
+
envAllowlist: Object.freeze([]),
|
|
24
|
+
protectedPaths: Object.freeze([]),
|
|
25
|
+
deny: Object.freeze([]),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const GUARDRAIL_LEVELS = Object.freeze(['permissive', 'normal', 'secure', 'custom']);
|
|
29
|
+
|
|
30
|
+
const deepFreeze = (o) => {
|
|
31
|
+
for (const v of Object.values(o)) {
|
|
32
|
+
if (v && typeof v === 'object' && !Object.isFrozen(v)) deepFreeze(v);
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze(o);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Credential-material file patterns every non-permissive level protects.
|
|
38
|
+
const NORMAL_PROTECTED = [
|
|
39
|
+
'.env*', // .env, .env.local, .env.production, .envrc — any depth
|
|
40
|
+
'*.pem', '*.key', // TLS / private key material
|
|
41
|
+
'id_rsa', 'id_ed25519', // bare SSH keys checked into odd places
|
|
42
|
+
'*.p12', '*.pfx', // bundled cert+key stores
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// Irreversible-publication commands no pipeline role ever needs.
|
|
46
|
+
const NORMAL_DENY = [
|
|
47
|
+
'Bash(git push)', 'Bash(git push:*)',
|
|
48
|
+
'Bash(npm publish)', 'Bash(npm publish:*)',
|
|
49
|
+
'Bash(yarn publish)', 'Bash(yarn publish:*)',
|
|
50
|
+
'Bash(pnpm publish)', 'Bash(pnpm publish:*)',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The built-in levels. `custom` is not here — it resolves from storage.
|
|
55
|
+
* permissive IS DEFAULT_GUARDRAILS (same object): an unconfigured project and a
|
|
56
|
+
* permissive project are indistinguishable, including byte-identical spawn argv.
|
|
57
|
+
* Normal: protect credential files, block publication; never breaks a pipeline
|
|
58
|
+
* (git commit / npm install / npm test / curl localhost all untouched).
|
|
59
|
+
* Secure++: Normal + env scrub (the real exfil control) + egress binaries +
|
|
60
|
+
* publish channels + cloud-credential CLIs + WebFetch/WebSearch (defense against
|
|
61
|
+
* frontmatter-widened agents). Still functional: project file Read/Write/Edit,
|
|
62
|
+
* npm install/test, and local git commits are untouched.
|
|
63
|
+
*/
|
|
64
|
+
export const GUARDRAIL_PRESETS = deepFreeze({
|
|
65
|
+
permissive: DEFAULT_GUARDRAILS,
|
|
66
|
+
normal: {
|
|
67
|
+
honorProjectSettings: true,
|
|
68
|
+
envScrub: false,
|
|
69
|
+
envAllowlist: [],
|
|
70
|
+
protectedPaths: [...NORMAL_PROTECTED],
|
|
71
|
+
deny: [...NORMAL_DENY],
|
|
72
|
+
},
|
|
73
|
+
secure: {
|
|
74
|
+
honorProjectSettings: true,
|
|
75
|
+
envScrub: true,
|
|
76
|
+
envAllowlist: [],
|
|
77
|
+
protectedPaths: [
|
|
78
|
+
...NORMAL_PROTECTED,
|
|
79
|
+
'.npmrc', '.netrc', // token-bearing rc files (project-level)
|
|
80
|
+
'*.tfstate*', // terraform state embeds raw secrets
|
|
81
|
+
'*.keystore', '*.jks',
|
|
82
|
+
'**/secrets/**', // slash-containing ⇒ needs **/ (anchoring)
|
|
83
|
+
'**/.git/config', // can embed https://user:token@ remotes
|
|
84
|
+
'~/.git-credentials', // git credential-store: plaintext https://user:token@ lines
|
|
85
|
+
'~/.ssh/**', '~/.aws/**', '~/.config/gcloud/**', '~/.kube/**', '~/.config/gh/**', // gh hosts.yml holds the OAuth token
|
|
86
|
+
'~/.npmrc', '~/.netrc', '~/.docker/config.json',
|
|
87
|
+
],
|
|
88
|
+
deny: [
|
|
89
|
+
...NORMAL_DENY,
|
|
90
|
+
'Bash(curl)', 'Bash(curl:*)', 'Bash(wget)', 'Bash(wget:*)',
|
|
91
|
+
'Bash(nc)', 'Bash(nc:*)', 'Bash(ncat)', 'Bash(ncat:*)', 'Bash(netcat)', 'Bash(netcat:*)',
|
|
92
|
+
'Bash(telnet)', 'Bash(telnet:*)',
|
|
93
|
+
'Bash(ssh)', 'Bash(ssh:*)', 'Bash(scp)', 'Bash(scp:*)', 'Bash(sftp)', 'Bash(sftp:*)',
|
|
94
|
+
'Bash(rsync)', 'Bash(rsync:*)', 'Bash(ftp)', 'Bash(ftp:*)',
|
|
95
|
+
'Bash(gh)', 'Bash(gh:*)',
|
|
96
|
+
'Bash(docker push)', 'Bash(docker push:*)',
|
|
97
|
+
'Bash(aws)', 'Bash(aws:*)', 'Bash(gcloud)', 'Bash(gcloud:*)', 'Bash(az)', 'Bash(az:*)',
|
|
98
|
+
'WebFetch', 'WebSearch',
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// A permission rule is `Tool(pattern)` (pattern non-empty), a bare tool name, a
|
|
104
|
+
// tool-name glob (`*` = all tools, `Bash*`), or an mcp__ tool id. Deliberately
|
|
105
|
+
// permissive inside the parens — the CLI owns pattern semantics; we only reject
|
|
106
|
+
// obvious shell text / malformed shapes. (The `*`/`Tool*` forms are valid deny
|
|
107
|
+
// globs per Claude Code docs; accepting them keeps the custom editor from 400ing
|
|
108
|
+
// a legitimate rule.)
|
|
109
|
+
const RULE_RE = /^(?:[A-Za-z*][A-Za-z0-9_*]*(?:\(.+\))?|mcp__[A-Za-z0-9_.*-]+(?:__[A-Za-z0-9_.*-]+)*)$/;
|
|
110
|
+
|
|
111
|
+
export function isPermissionRule(s) {
|
|
112
|
+
return typeof s === 'string' && RULE_RE.test(s.trim());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cleanStrings = (v) =>
|
|
116
|
+
Array.isArray(v) ? v.map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean) : [];
|
|
117
|
+
|
|
118
|
+
/** Read-path sanitizer for the 5-key settings shape. Malformed → defaults; invalid deny rules drop. */
|
|
119
|
+
export function sanitizeGuardrails(raw) {
|
|
120
|
+
const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
121
|
+
return {
|
|
122
|
+
honorProjectSettings:
|
|
123
|
+
typeof src.honorProjectSettings === 'boolean' ? src.honorProjectSettings : true,
|
|
124
|
+
envScrub: src.envScrub === true,
|
|
125
|
+
envAllowlist: cleanStrings(src.envAllowlist),
|
|
126
|
+
protectedPaths: cleanStrings(src.protectedPaths),
|
|
127
|
+
deny: cleanStrings(src.deny).filter(isPermissionRule),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Write-path validator for the 5-key settings shape: strict, collects errors, never throws. */
|
|
132
|
+
export function validateGuardrails(raw) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
if (raw === undefined || raw === null) return { ok: true, errors };
|
|
135
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, errors: ['guardrails must be an object'] };
|
|
136
|
+
for (const k of ['honorProjectSettings', 'envScrub']) {
|
|
137
|
+
if (k in raw && typeof raw[k] !== 'boolean') errors.push(`${k} must be a boolean`);
|
|
138
|
+
}
|
|
139
|
+
for (const k of ['envAllowlist', 'protectedPaths', 'deny']) {
|
|
140
|
+
if (k in raw) {
|
|
141
|
+
if (!Array.isArray(raw[k])) errors.push(`${k} must be an array of strings`);
|
|
142
|
+
else for (const s of raw[k]) if (typeof s !== 'string' || !s.trim()) errors.push(`${k} entries must be non-empty strings`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(raw.deny)) {
|
|
146
|
+
for (const r of raw.deny) {
|
|
147
|
+
if (typeof r === 'string' && r.trim() && !isPermissionRule(r)) {
|
|
148
|
+
errors.push(`deny rule "${r}" is not a valid permission rule (expected Tool(pattern))`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const known = new Set(['honorProjectSettings', 'envScrub', 'envAllowlist', 'protectedPaths', 'deny']);
|
|
153
|
+
for (const k of Object.keys(raw)) if (!known.has(k)) errors.push(`unknown key "${k}"`);
|
|
154
|
+
return { ok: errors.length === 0, errors };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const FIVE_KEYS = ['honorProjectSettings', 'envScrub', 'envAllowlist', 'protectedPaths', 'deny'];
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Read-path sanitizer for the STORED config shape.
|
|
161
|
+
* { level, custom } passes through (bad level fails open to permissive — parity);
|
|
162
|
+
* a v1-era bare 5-key blob upgrades losslessly to { level:'custom', custom };
|
|
163
|
+
* anything else → { level:'permissive', custom:null }.
|
|
164
|
+
* Lenient about unknown wrapper keys by design (a future v3 blob read by v2
|
|
165
|
+
* degrades gracefully); the WRITE path (validateGuardrailsConfig) stays strict.
|
|
166
|
+
*/
|
|
167
|
+
export function sanitizeGuardrailsConfig(raw) {
|
|
168
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { level: 'permissive', custom: null };
|
|
169
|
+
const custom = raw.custom && typeof raw.custom === 'object' && !Array.isArray(raw.custom)
|
|
170
|
+
? sanitizeGuardrails(raw.custom)
|
|
171
|
+
: null;
|
|
172
|
+
if (typeof raw.level === 'string') {
|
|
173
|
+
return GUARDRAIL_LEVELS.includes(raw.level)
|
|
174
|
+
? { level: raw.level, custom }
|
|
175
|
+
: { level: 'permissive', custom };
|
|
176
|
+
}
|
|
177
|
+
if (FIVE_KEYS.some((k) => k in raw)) return { level: 'custom', custom: sanitizeGuardrails(raw) };
|
|
178
|
+
return { level: 'permissive', custom: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Write-path validator for the { level, custom? } shape (the API's 400 source).
|
|
183
|
+
* @param {object} raw
|
|
184
|
+
* @param {{hasStoredCustom?: boolean}} opts whether the DB already holds a custom blob
|
|
185
|
+
*/
|
|
186
|
+
export function validateGuardrailsConfig(raw, { hasStoredCustom = false } = {}) {
|
|
187
|
+
const errors = [];
|
|
188
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
189
|
+
return { ok: false, errors: ['guardrails must be an object'] };
|
|
190
|
+
}
|
|
191
|
+
if (!GUARDRAIL_LEVELS.includes(raw.level)) {
|
|
192
|
+
errors.push(`level must be one of: ${GUARDRAIL_LEVELS.join(', ')}`);
|
|
193
|
+
}
|
|
194
|
+
const hasCustomPayload = raw.custom !== undefined && raw.custom !== null;
|
|
195
|
+
if (hasCustomPayload) {
|
|
196
|
+
const v = validateGuardrails(raw.custom);
|
|
197
|
+
errors.push(...v.errors);
|
|
198
|
+
} else if (raw.level === 'custom' && !hasStoredCustom) {
|
|
199
|
+
errors.push('custom guardrail settings required when level is "custom"');
|
|
200
|
+
}
|
|
201
|
+
const known = new Set(['level', 'custom']);
|
|
202
|
+
for (const k of Object.keys(raw)) if (!known.has(k)) errors.push(`unknown key "${k}"`);
|
|
203
|
+
return { ok: errors.length === 0, errors };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Stored config → EFFECTIVE 5-key settings. Preset levels resolve from the code
|
|
208
|
+
* table (fresh copies — the frozen table never leaks); custom resolves from the
|
|
209
|
+
* stored blob (null custom degrades to the empty policy).
|
|
210
|
+
*/
|
|
211
|
+
export function resolveGuardrails(stored) {
|
|
212
|
+
const cfg = sanitizeGuardrailsConfig(stored);
|
|
213
|
+
if (cfg.level === 'custom') return cfg.custom ? { ...cfg.custom, envAllowlist: [...cfg.custom.envAllowlist], protectedPaths: [...cfg.custom.protectedPaths], deny: [...cfg.custom.deny] } : sanitizeGuardrails(undefined);
|
|
214
|
+
return sanitizeGuardrails(GUARDRAIL_PRESETS[cfg.level]);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const presetKey = (g) => {
|
|
218
|
+
const s = sanitizeGuardrails(g);
|
|
219
|
+
return JSON.stringify({
|
|
220
|
+
honorProjectSettings: s.honorProjectSettings,
|
|
221
|
+
envScrub: s.envScrub,
|
|
222
|
+
envAllowlist: [...new Set(s.envAllowlist)].sort(),
|
|
223
|
+
protectedPaths: [...new Set(s.protectedPaths)].sort(),
|
|
224
|
+
deny: [...new Set(s.deny)].sort(),
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Which built-in preset a 5-key settings object equals, or null (⇒ Custom).
|
|
230
|
+
* Order-insensitive: the lists are semantically sets (everything downstream
|
|
231
|
+
* de-dupes/unions), so a reordered list must not read as "customised".
|
|
232
|
+
*/
|
|
233
|
+
export function detectPreset(settings) {
|
|
234
|
+
const key = presetKey(settings);
|
|
235
|
+
for (const level of ['permissive', 'normal', 'secure']) {
|
|
236
|
+
if (presetKey(GUARDRAIL_PRESETS[level]) === key) return level;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Expand 5-key settings into Claude Code permission rules. null when empty. */
|
|
242
|
+
export function guardrailsToPermissionRules(g) {
|
|
243
|
+
const gg = sanitizeGuardrails(g);
|
|
244
|
+
const deny = [];
|
|
245
|
+
const push = (r) => { if (!deny.includes(r)) deny.push(r); };
|
|
246
|
+
// Read + Edit ONLY. Claude Code consults only Read/Edit path rules for file
|
|
247
|
+
// permissions (Edit covers all file-editing tools: Write/NotebookEdit); a
|
|
248
|
+
// Write() rule is never consulted AND prints a stderr warning per rule per
|
|
249
|
+
// spawn on CLI 2.1.210+ (which runReal folds into the failure message). Read
|
|
250
|
+
// is the load-bearing secret guard; a Read deny also blocks Edit (≥2.1.208).
|
|
251
|
+
for (const p of gg.protectedPaths) { push(`Read(${p})`); push(`Edit(${p})`); }
|
|
252
|
+
for (const r of gg.deny) push(r);
|
|
253
|
+
return deny.length ? { deny } : null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** De-duped union of two {deny?,allow?,ask?} rule objects. null when empty. */
|
|
257
|
+
export function mergePermissionRules(a, b) {
|
|
258
|
+
const out = {};
|
|
259
|
+
for (const key of ['deny', 'allow', 'ask']) {
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
const arr = [];
|
|
262
|
+
for (const src of [a, b]) {
|
|
263
|
+
for (const r of Array.isArray(src?.[key]) ? src[key] : []) {
|
|
264
|
+
if (typeof r === 'string' && r.trim() && !seen.has(r)) { seen.add(r); arr.push(r); }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (arr.length) out[key] = arr;
|
|
268
|
+
}
|
|
269
|
+
return Object.keys(out).length ? out : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Deny-safe union across workspace members' EFFECTIVE settings: any member
|
|
274
|
+
* scrubbing scrubs the run; the RESTRICTION lists (protectedPaths, deny) union
|
|
275
|
+
* de-duped across ALL members; the envAllowlist — a WIDENER — unions ONLY over
|
|
276
|
+
* members that actually scrub, so a non-scrubbing member's dormant allowlist can
|
|
277
|
+
* never punch a hole in another member's scrub. More guarding always wins; a
|
|
278
|
+
* member can never relax another member's policy. (Permissive member + Secure
|
|
279
|
+
* member ⇒ Secure for the whole run — by design; surfaced in docs (Task 12) and
|
|
280
|
+
* the panel hint (Task 10).)
|
|
281
|
+
* NOTE: `honorProjectSettings` is unioned here for shape completeness only and is
|
|
282
|
+
* ADVISORY — the repo-settings lift is gated PER MEMBER by each member's own
|
|
283
|
+
* honorProjectSettings (Task 6/7), never by this any-true scalar.
|
|
284
|
+
*/
|
|
285
|
+
export function unionGuardrails(list) {
|
|
286
|
+
const gs = (Array.isArray(list) ? list : []).map(sanitizeGuardrails);
|
|
287
|
+
if (!gs.length) return sanitizeGuardrails(undefined);
|
|
288
|
+
const unionList = (src, key) => {
|
|
289
|
+
const seen = new Set();
|
|
290
|
+
const arr = [];
|
|
291
|
+
for (const g of src) for (const s of g[key]) if (!seen.has(s)) { seen.add(s); arr.push(s); }
|
|
292
|
+
return arr;
|
|
293
|
+
};
|
|
294
|
+
const scrubbers = gs.filter((g) => g.envScrub);
|
|
295
|
+
return {
|
|
296
|
+
honorProjectSettings: gs.some((g) => g.honorProjectSettings),
|
|
297
|
+
envScrub: scrubbers.length > 0,
|
|
298
|
+
envAllowlist: unionList(scrubbers, 'envAllowlist'),
|
|
299
|
+
protectedPaths: unionList(gs, 'protectedPaths'),
|
|
300
|
+
deny: unionList(gs, 'deny'),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// src/core/marketplaces.mjs
|
|
2
|
+
// Persisted plugin-marketplace registry (marketplace spec §4.2): a git repo
|
|
3
|
+
// (URL, owner/repo, or local path) registered as a discovery source, with a
|
|
4
|
+
// cached discovery snapshot so the Plugins view renders with zero network.
|
|
5
|
+
// File conventions mirror plugins-lock.mjs: reads never throw, writes are
|
|
6
|
+
// temp+rename atomic, unknown keys survive read-modify-write.
|
|
7
|
+
// Installed plugins do NOT depend on this registry — the lock's own
|
|
8
|
+
// repo/subdir/pinnedSha provenance keeps update/uninstall working after a
|
|
9
|
+
// marketplace is removed (spec §4.5).
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, rmSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { join, resolve, dirname } from 'node:path';
|
|
15
|
+
import { randomBytes } from 'node:crypto';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { pluginsRoot, readPluginsLock } from './plugins-lock.mjs';
|
|
18
|
+
import { addPluginRepo, repoCacheDir, repoSlug } from './plugin-repo.mjs';
|
|
19
|
+
import { inventoryFromCache } from './plugin-inventory.mjs';
|
|
20
|
+
|
|
21
|
+
export function marketplacesFile() { return join(pluginsRoot(), 'marketplaces.json'); }
|
|
22
|
+
|
|
23
|
+
/** owner/repo -> GitHub URL (unless a real local path); URLs lose trailing /
|
|
24
|
+
* and .git; local paths resolve to absolute. null for empty input. */
|
|
25
|
+
export function normalizeMarketplaceUrl(input) {
|
|
26
|
+
let s = String(input ?? '').trim();
|
|
27
|
+
if (!s) return null;
|
|
28
|
+
if (/^[\w.-]+\/[\w.-]+$/.test(s) && !existsSync(s)) s = `https://github.com/${s}`;
|
|
29
|
+
if (/^[a-z+]+:\/\//i.test(s)) return s.replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
30
|
+
// scp-style git remote ([user@]host:path) — a git URL, never a local path.
|
|
31
|
+
if (/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:(?!\/)/.test(s)) return s.replace(/\/+$/, '').replace(/\.git$/i, '');
|
|
32
|
+
return resolve(s);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// normalizeMarketplaceUrl is idempotent (C2), so hashing the normalized url is
|
|
36
|
+
// stable even when addMarketplace has already normalized before calling.
|
|
37
|
+
export function marketplaceId(url) { return repoSlug(normalizeMarketplaceUrl(url) ?? String(url)); }
|
|
38
|
+
|
|
39
|
+
/** Missing/corrupt/non-object -> empty state. Entries not normalized on read. */
|
|
40
|
+
export function readMarketplaces() {
|
|
41
|
+
try {
|
|
42
|
+
const v = JSON.parse(readFileSync(marketplacesFile(), 'utf8'));
|
|
43
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
44
|
+
return {
|
|
45
|
+
...v, // unknown keys survive read-modify-write; normalized fields below win
|
|
46
|
+
seededBuiltin: v.seededBuiltin === true,
|
|
47
|
+
marketplaces: v.marketplaces && typeof v.marketplaces === 'object' && !Array.isArray(v.marketplaces)
|
|
48
|
+
? Object.assign(Object.create(null), v.marketplaces) : Object.create(null),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
} catch { /* fall through */ }
|
|
52
|
+
return { seededBuiltin: false, marketplaces: Object.create(null) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function writeMarketplaces(state) {
|
|
56
|
+
const file = marketplacesFile();
|
|
57
|
+
mkdirSync(pluginsRoot(), { recursive: true });
|
|
58
|
+
const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
59
|
+
writeFileSync(tmp, JSON.stringify(state ?? { seededBuiltin: false, marketplaces: {} }, null, 2) + '\n', 'utf8');
|
|
60
|
+
renameSync(tmp, file);
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// relTimeCore(iso, now) -> "just now"/"5m ago"/"3h ago"/"2d ago"/ISO date. Pure;
|
|
65
|
+
// mirrors plugins-view.mjs relTime so core has no DOM dependency (C4).
|
|
66
|
+
function relTimeCore(iso, now = Date.now()) {
|
|
67
|
+
const t = Date.parse(iso);
|
|
68
|
+
if (!Number.isFinite(t)) return String(iso || 'unknown');
|
|
69
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
70
|
+
if (s < 45) return 'just now';
|
|
71
|
+
const m = Math.round(s / 60); if (m < 60) return `${m}m ago`;
|
|
72
|
+
const h = Math.round(m / 60); if (h < 24) return `${h}h ago`;
|
|
73
|
+
const d = Math.round(h / 24); if (d < 30) return `${d}d ago`;
|
|
74
|
+
return String(iso).slice(0, 10);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Reduce a multi-line git failure to its first `fatal:` line; map the empty-repo
|
|
78
|
+
* case to plain language. Avoids leaking the internal .cache path into the UI. */
|
|
79
|
+
function firstFatal(err) {
|
|
80
|
+
const raw = String(err?.stderr || err?.message || err || '');
|
|
81
|
+
if (/unknown revision|ambiguous argument 'HEAD'/.test(raw)) return 'repository has no commits yet';
|
|
82
|
+
return raw.split('\n').map((l) => l.trim()).find((l) => l.startsWith('fatal:'))?.replace(/^fatal:\s*/, '')
|
|
83
|
+
|| (raw.split('\n')[0] || 'unknown error');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Discovery + per-plugin consent inventories -> fresh snapshot on the entry.
|
|
87
|
+
* Failure keeps the previous snapshot and records a warning (stale-but-usable). */
|
|
88
|
+
async function syncEntry(entry, { exec } = {}) {
|
|
89
|
+
try {
|
|
90
|
+
const found = await addPluginRepo(entry.url, ...(exec ? [{ exec }] : []));
|
|
91
|
+
const plugins = [];
|
|
92
|
+
for (const d of found.discovered) {
|
|
93
|
+
plugins.push({
|
|
94
|
+
name: d.name,
|
|
95
|
+
subdir: d.subdir,
|
|
96
|
+
description: d.manifest.description ?? '',
|
|
97
|
+
version: d.manifest.version ?? null,
|
|
98
|
+
inventory: await inventoryFromCache(found.repoUrl, found.sha, d.subdir, ...(exec ? [{ exec }] : [])),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (found.marketplace) {
|
|
102
|
+
entry.name = found.marketplace.name;
|
|
103
|
+
entry.description = found.marketplace.description;
|
|
104
|
+
}
|
|
105
|
+
entry.lastSync = { sha: found.sha, at: new Date().toISOString() };
|
|
106
|
+
entry.plugins = plugins;
|
|
107
|
+
entry.warnings = found.warnings;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const when = entry.lastSync ? `last sync ${relTimeCore(entry.lastSync.at)}` : 'never synced';
|
|
110
|
+
const msg = firstFatal(err);
|
|
111
|
+
entry.warnings = [`${when}; refresh failed: ${msg}`,
|
|
112
|
+
...(entry.warnings || []).filter((w) => !/refresh failed:/.test(w))];
|
|
113
|
+
}
|
|
114
|
+
return entry;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Register + immediately sync. Throws EXISTS on a duplicate (normalized) url.
|
|
118
|
+
* A first-sync failure throws WITHOUT recording — a typo'd url never leaves a
|
|
119
|
+
* junk entry (spec §4.2). The insert RE-READS the registry after the long git
|
|
120
|
+
* sync (B8) — inserting into the pre-sync snapshot would resurrect entries
|
|
121
|
+
* removed meanwhile and drop concurrent adds (lost update). */
|
|
122
|
+
export async function addMarketplace(url, { exec } = {}) {
|
|
123
|
+
const norm = normalizeMarketplaceUrl(url);
|
|
124
|
+
if (!norm) throw Object.assign(new Error('marketplace url is required'), { code: 'BAD_REQUEST' });
|
|
125
|
+
const id = marketplaceId(norm);
|
|
126
|
+
if (readMarketplaces().marketplaces[id]) { // cheap pre-check: fail before any git work
|
|
127
|
+
throw Object.assign(new Error(`marketplace already added: ${norm}`), { code: 'EXISTS' });
|
|
128
|
+
}
|
|
129
|
+
const entry = {
|
|
130
|
+
id, url: norm, name: id, description: '',
|
|
131
|
+
addedAt: new Date().toISOString(), lastSync: null, plugins: [], warnings: [],
|
|
132
|
+
};
|
|
133
|
+
await syncEntry(entry, { exec });
|
|
134
|
+
if (!entry.lastSync) {
|
|
135
|
+
// C8: drop the orphan bare cache the failed clone left — but never a cache an
|
|
136
|
+
// installed plugin still shares (transient failures must not evict it).
|
|
137
|
+
const inUse = Object.values(readPluginsLock()).some((e) =>
|
|
138
|
+
e && e.repo && (e.repo === norm || normalizeMarketplaceUrl(e.repo) === norm));
|
|
139
|
+
if (!inUse) rmSync(repoCacheDir(norm), { recursive: true, force: true });
|
|
140
|
+
throw Object.assign(new Error(entry.warnings[0] || `could not read ${norm}`), { code: 'BAD_REQUEST' });
|
|
141
|
+
}
|
|
142
|
+
const state = readMarketplaces(); // B8: LATEST state, not the pre-sync snapshot
|
|
143
|
+
if (state.marketplaces[id]) {
|
|
144
|
+
throw Object.assign(new Error(`marketplace already added: ${norm}`), { code: 'EXISTS' });
|
|
145
|
+
}
|
|
146
|
+
state.marketplaces[id] = entry;
|
|
147
|
+
writeMarketplaces(state);
|
|
148
|
+
return entry;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Read-modify-write the LATEST state, one entry only — so a long git sync can't
|
|
152
|
+
* clobber a concurrent remove/add. Drops the write if the entry vanished. */
|
|
153
|
+
function mutateEntry(id, apply) {
|
|
154
|
+
const state = readMarketplaces();
|
|
155
|
+
if (!Object.hasOwn(state.marketplaces, id)) return null;
|
|
156
|
+
apply(state.marketplaces[id]);
|
|
157
|
+
writeMarketplaces(state);
|
|
158
|
+
return state.marketplaces[id];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function syncMarketplace(id, { exec } = {}) {
|
|
162
|
+
const cur = readMarketplaces().marketplaces[id];
|
|
163
|
+
if (!cur) throw Object.assign(new Error(`marketplace "${id}" not found`), { code: 'NOT_FOUND' });
|
|
164
|
+
const clone = { ...cur, plugins: [...(cur.plugins || [])], warnings: [...(cur.warnings || [])] };
|
|
165
|
+
await syncEntry(clone, { exec });
|
|
166
|
+
const saved = mutateEntry(id, (e) => Object.assign(e, {
|
|
167
|
+
name: clone.name, description: clone.description, lastSync: clone.lastSync,
|
|
168
|
+
plugins: clone.plugins, warnings: clone.warnings,
|
|
169
|
+
}));
|
|
170
|
+
if (!saved) throw Object.assign(new Error(`marketplace "${id}" not found`), { code: 'NOT_FOUND' });
|
|
171
|
+
return saved;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Sequential, per-entry tolerant: a dead repo (sync warning) AND an entry removed
|
|
175
|
+
* mid-refresh (NOT_FOUND) both leave the others intact. */
|
|
176
|
+
export async function refreshAllMarketplaces({ exec } = {}) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const id of Object.keys(readMarketplaces().marketplaces).sort()) {
|
|
179
|
+
try { out.push(await syncMarketplace(id, { exec })); }
|
|
180
|
+
catch (err) { if (err?.code !== 'NOT_FOUND') throw err; }
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Remove the registry entry + snapshot. Installed plugins are untouched; the
|
|
186
|
+
* bare cache goes only when no plugins.lock.json entry shares the repo. */
|
|
187
|
+
export function removeMarketplace(id) {
|
|
188
|
+
const state = readMarketplaces();
|
|
189
|
+
const entry = state.marketplaces[id];
|
|
190
|
+
if (!entry) throw Object.assign(new Error(`marketplace "${id}" not found`), { code: 'NOT_FOUND' });
|
|
191
|
+
const inUse = Object.values(readPluginsLock()).some((e) =>
|
|
192
|
+
e && e.repo && (e.repo === entry.url || normalizeMarketplaceUrl(e.repo) === entry.url));
|
|
193
|
+
delete state.marketplaces[id];
|
|
194
|
+
writeMarketplaces(state);
|
|
195
|
+
if (!inUse) rmSync(repoCacheDir(entry.url), { recursive: true, force: true });
|
|
196
|
+
return { ok: true, id };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function listMarketplaces() {
|
|
200
|
+
const state = readMarketplaces();
|
|
201
|
+
return Object.values(state.marketplaces)
|
|
202
|
+
.sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id)));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Install-source resolution (spec §4.10): explicit --repo > lock provenance >
|
|
206
|
+
* unique snapshot match > {candidates} on ambiguity > null. */
|
|
207
|
+
export function resolveInstallSource(name, { repo } = {}) {
|
|
208
|
+
if (repo) return { repoUrl: normalizeMarketplaceUrl(repo), subdir: null, sha: null, marketplace: null };
|
|
209
|
+
const lockEntry = readPluginsLock()[name];
|
|
210
|
+
if (lockEntry && lockEntry.repo) {
|
|
211
|
+
return { repoUrl: lockEntry.repo, subdir: lockEntry.subdir ?? null, sha: null, marketplace: lockEntry.marketplace ?? null };
|
|
212
|
+
}
|
|
213
|
+
const hits = [];
|
|
214
|
+
for (const m of listMarketplaces()) {
|
|
215
|
+
for (const p of m.plugins || []) {
|
|
216
|
+
if (p.name === name) {
|
|
217
|
+
hits.push({ repoUrl: m.url, subdir: p.subdir, sha: m.lastSync ? m.lastSync.sha : null, marketplace: m.id });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (hits.length === 1) return hits[0];
|
|
222
|
+
if (hits.length > 1) return { candidates: hits };
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The checkout the host code runs from: two dirs above src/core/. Only a real
|
|
227
|
+
* marketplace checkout counts (must have worca-cc-marketplace.json + .git) —
|
|
228
|
+
* an npm-dist install without either returns null and seeding is skipped. */
|
|
229
|
+
export function hostRepoRoot() {
|
|
230
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
231
|
+
return existsSync(join(root, 'worca-cc-marketplace.json')) && existsSync(join(root, '.git'))
|
|
232
|
+
? root : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** First-run builtin seed (spec §4.2): register the host checkout as a
|
|
236
|
+
* local-path marketplace. NO git operations — plugins:[] / lastSync:null; the
|
|
237
|
+
* first sync happens via the Plugins view's background refresh or an explicit
|
|
238
|
+
* `worca marketplace refresh`. seededBuiltin is set only on success, so a
|
|
239
|
+
* removed builtin never auto-returns, while a non-checkout host stays eligible
|
|
240
|
+
* to seed on a later run from a real checkout. */
|
|
241
|
+
export function seedBuiltinMarketplace({ rootDir = hostRepoRoot() } = {}) {
|
|
242
|
+
const state = readMarketplaces();
|
|
243
|
+
if (state.seededBuiltin) return { seeded: false, reason: 'already-seeded' };
|
|
244
|
+
// Same contract as hostRepoRoot for an INJECTED rootDir too: a real checkout must
|
|
245
|
+
// carry both the manifest and .git, else this is an npm-dist dir — skip (E7).
|
|
246
|
+
if (!rootDir || !existsSync(join(rootDir, 'worca-cc-marketplace.json')) || !existsSync(join(rootDir, '.git'))) {
|
|
247
|
+
return { seeded: false, reason: 'no-host-checkout' };
|
|
248
|
+
}
|
|
249
|
+
const url = resolve(rootDir);
|
|
250
|
+
const id = marketplaceId(url);
|
|
251
|
+
let name = 'Worca CC Official';
|
|
252
|
+
let description = '';
|
|
253
|
+
try {
|
|
254
|
+
const raw = JSON.parse(readFileSync(join(rootDir, 'worca-cc-marketplace.json'), 'utf8'));
|
|
255
|
+
if (typeof raw?.name === 'string' && raw.name.trim()) name = raw.name.trim();
|
|
256
|
+
if (typeof raw?.description === 'string') description = raw.description.trim();
|
|
257
|
+
} catch { /* keep defaults */ }
|
|
258
|
+
if (!state.marketplaces[id]) {
|
|
259
|
+
state.marketplaces[id] = {
|
|
260
|
+
id, url, name, description, builtin: true,
|
|
261
|
+
addedAt: new Date().toISOString(), lastSync: null, plugins: [], warnings: [],
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
state.seededBuiltin = true;
|
|
265
|
+
writeMarketplaces(state);
|
|
266
|
+
return { seeded: true, id };
|
|
267
|
+
}
|