yay-layer 1.0.0-rc.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/src/gate.js ADDED
@@ -0,0 +1,252 @@
1
+ 'use strict';
2
+ // `yay gate` — bootstrap the enforcement gate for a project on ANY git host:
3
+ // • write the CI pipeline that runs `yay verify --strict` on PRs/MRs,
4
+ // • optionally install a local pre-push hook for solo/offline feedback,
5
+ // • print the branch-protection steps (which live in the host's settings and
6
+ // can't be scripted with a normal token).
7
+ // The gate itself is platform-agnostic — the ONLY host-specific part is the pipeline
8
+ // file's syntax and where branch protection lives. `--for <platform>` picks that.
9
+ // String generators are pure (easy to test); file writes are separate.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ const WORKFLOW_REL = '.github/workflows/yaylayer.yml'; // default (github), kept for back-compat
15
+ const NPM_NOTE = "# Until yay-layer is published to npm, change the install line to: npm install -g github:jonas-developer/yay-layer";
16
+ const args = ({ scope = '', root = '' }) => (scope ? ` --dir ${scope}` : '') + (root ? ` --root ${root}` : '');
17
+
18
+ // ── per-platform CI pipeline generators (all just: install node → install yay → verify --strict) ──
19
+ function githubWorkflow(o = {}) {
20
+ return `# Generated by \`yay gate\`. The real gate is this check PLUS a branch-protection rule on
21
+ # main that REQUIRES the "gate" check (see the printed steps).
22
+ ${NPM_NOTE}
23
+ name: YayLayer
24
+ on:
25
+ pull_request:
26
+ push:
27
+ branches: [main]
28
+ jobs:
29
+ gate:
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+ - uses: actions/setup-node@v4
34
+ with:
35
+ node-version: '20'
36
+ - name: Install YayLayer
37
+ run: npm install -g ${o.pkg || 'yay-layer'}
38
+ - name: Verify the gate
39
+ run: yay verify --strict${args(o)}
40
+ `;
41
+ }
42
+ function azureWorkflow(o = {}) {
43
+ return `# Generated by \`yay gate --for azure\`. The real gate is this pipeline PLUS a Branch Policy
44
+ # on main that REQUIRES it (Repos → Branches → main → Branch policies → Build Validation).
45
+ ${NPM_NOTE}
46
+ trigger:
47
+ branches: { include: [main] }
48
+ pr:
49
+ branches: { include: [main] }
50
+ pool:
51
+ vmImage: ubuntu-latest
52
+ steps:
53
+ - task: NodeTool@0
54
+ inputs:
55
+ versionSpec: '20.x'
56
+ - script: npm install -g ${o.pkg || 'yay-layer'}
57
+ displayName: Install YayLayer
58
+ - script: yay verify --strict${args(o)}
59
+ displayName: YayLayer gate
60
+ `;
61
+ }
62
+ function gitlabWorkflow(o = {}) {
63
+ return `# Generated by \`yay gate --for gitlab\`. The real gate is this job PLUS a Protected branch on
64
+ # main with "Pipelines must succeed" + merge-request approvals (Settings → Repository / Merge requests).
65
+ ${NPM_NOTE}
66
+ stages: [verify]
67
+ yaylayer-gate:
68
+ stage: verify
69
+ image: node:20
70
+ script:
71
+ - npm install -g ${o.pkg || 'yay-layer'}
72
+ - yay verify --strict${args(o)}
73
+ rules:
74
+ - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
75
+ - if: '$CI_COMMIT_BRANCH == "main"'
76
+ `;
77
+ }
78
+ function bitbucketWorkflow(o = {}) {
79
+ const step = ` - step:\n name: YayLayer gate\n script:\n - npm install -g ${o.pkg || 'yay-layer'}\n - yay verify --strict${args(o)}`;
80
+ return `# Generated by \`yay gate --for bitbucket\`. The real gate is this pipeline PLUS a Branch
81
+ # restriction on main requiring a passing build + a PR (Repository settings → Branch restrictions).
82
+ ${NPM_NOTE}
83
+ image: node:20
84
+ pipelines:
85
+ pull-requests:
86
+ '**':
87
+ ${step}
88
+ branches:
89
+ main:
90
+ ${step}
91
+ `;
92
+ }
93
+
94
+ // Gerrit has no native in-repo pipeline: it gates via the "Verified" label + a submit requirement,
95
+ // and delegates the actual run to Zuul (its native check/gate CI) or Jenkins. We emit a Zuul config
96
+ // (+ its companion playbook, since a Zuul job runs a playbook, not an inline command).
97
+ function gerritZuul(o = {}) {
98
+ return `# Generated by \`yay gate --for gerrit\`. Gerrit gates via the "Verified" LABEL, not a required
99
+ # "check": your CI runs on refs/for/* and votes Verified +1/-1, and you make Verified+1 a SUBMIT
100
+ # REQUIREMENT so nothing merges until the gate is green (see the printed steps). This is a Zuul config
101
+ # (Gerrit's native check/gate CI) — it runs playbooks/yaylayer-gate.yaml. Using Jenkins instead? Run
102
+ # the same \`yay verify --strict\` in your job via the Gerrit Trigger plugin; the contract is identical.
103
+ ${NPM_NOTE}
104
+ - job:
105
+ name: yaylayer-gate
106
+ description: YayLayer verifier gate (fails on Red / Unsigned / Pink).
107
+ run: playbooks/yaylayer-gate.yaml
108
+ nodeset:
109
+ nodes:
110
+ - name: ubuntu
111
+ label: ubuntu-jammy # depends on your Zuul deployment — change to a label it provides
112
+ - project:
113
+ check:
114
+ jobs: [yaylayer-gate]
115
+ gate:
116
+ jobs: [yaylayer-gate]
117
+ `;
118
+ }
119
+ function gerritPlaybook(o = {}) {
120
+ return `# Generated by \`yay gate --for gerrit\` (companion to .zuul.yaml — the job's playbook).
121
+ - hosts: all
122
+ tasks:
123
+ - name: Install YayLayer
124
+ shell: npm install -g ${o.pkg || 'yay-layer'}
125
+ - name: Verify the gate
126
+ shell: yay verify --strict${args(o)}
127
+ `;
128
+ }
129
+
130
+ // gitea/forgejo run GitHub-Actions-compatible workflows — same content, different path.
131
+ const PLATFORMS = {
132
+ github: { rel: '.github/workflows/yaylayer.yml', workflow: githubWorkflow },
133
+ gitea: { rel: '.gitea/workflows/yaylayer.yml', workflow: githubWorkflow },
134
+ azure: { rel: 'azure-pipelines.yml', workflow: azureWorkflow },
135
+ gitlab: { rel: '.gitlab-ci.yml', workflow: gitlabWorkflow },
136
+ bitbucket: { rel: 'bitbucket-pipelines.yml', workflow: bitbucketWorkflow },
137
+ gerrit: { rel: '.zuul.yaml', workflow: gerritZuul, extra: [{ rel: 'playbooks/yaylayer-gate.yaml', gen: gerritPlaybook }] },
138
+ };
139
+ const PLATFORM_KEYS = Object.keys(PLATFORMS);
140
+ const normPlatform = (p) => { const k = String(p || 'github').toLowerCase().replace('devops', '').replace('forgejo', 'gitea').trim(); return PLATFORMS[k] ? k : (k === 'ado' ? 'azure' : 'github'); };
141
+
142
+ // Back-compat: the bare github generator.
143
+ function ciWorkflow(o = {}) { return (PLATFORMS[normPlatform(o.platform)] || PLATFORMS.github).workflow(o); }
144
+
145
+ function prePushHook({ scope = '' } = {}) {
146
+ const scopeArg = scope ? ` --dir ${scope}` : '';
147
+ return `#!/bin/sh
148
+ # YayLayer local gate — refuses a push while any Cell is Red / Unsigned / Pink.
149
+ # This is fast local feedback, NOT the real guarantee (bypass with: git push --no-verify).
150
+ # The real gate is the CI check + branch protection on main.
151
+ command -v yay >/dev/null 2>&1 || { echo "yaylayer: 'yay' not on PATH — skipping local gate"; exit 0; }
152
+ yay verify --strict${scopeArg} || {
153
+ echo ""
154
+ echo " push blocked by YayLayer — resolve Red/Unsigned/Pink (yay verify --problems), or: git push --no-verify"
155
+ exit 1
156
+ }
157
+ `;
158
+ }
159
+
160
+ function branchProtectionSteps(platform) {
161
+ const p = normPlatform(platform);
162
+ const common = 'Now nothing merges to main until the gate is green (no Red / Unsigned / Pink; Yellow is allowed).\nBranch protection lives in your host\'s settings only you control — a token cannot set it.';
163
+ if (p === 'azure') return [
164
+ 'To make it enforce, add a Branch Policy on main (one-time, in Azure DevOps):',
165
+ '',
166
+ ' 1. Commit azure-pipelines.yml, then Pipelines → New pipeline → point it at this repo/file and run it once.',
167
+ ' 2. Repos → Branches → hover main → ⋯ → Branch policies.',
168
+ ' 3. Build Validation → + → pick this pipeline; Trigger: Automatic; Policy requirement: Required.',
169
+ ' 4. Also enable "Require a minimum number of reviewers" (≥1) so changes go through a PR.',
170
+ '', common,
171
+ ].join('\n');
172
+ if (p === 'gitlab') return [
173
+ 'To make it enforce (one-time, in GitLab):',
174
+ '',
175
+ ' 1. Commit .gitlab-ci.yml and let a pipeline run once.',
176
+ ' 2. Settings → Repository → Protected branches → protect main (Allowed to merge: Maintainers).',
177
+ ' 3. Settings → Merge requests → tick "Pipelines must succeed"; set required approvals ≥ 1.',
178
+ '', common,
179
+ ].join('\n');
180
+ if (p === 'bitbucket') return [
181
+ 'To make it enforce (one-time, in Bitbucket):',
182
+ '',
183
+ ' 1. Commit bitbucket-pipelines.yml (enable Pipelines in Repository settings) and let it run once.',
184
+ ' 2. Repository settings → Branch restrictions → add a restriction on main.',
185
+ ' 3. Require: a pull request, a minimum number of approvals (≥1), and "successful builds" before merging.',
186
+ '', common,
187
+ ].join('\n');
188
+ if (p === 'gerrit') return [
189
+ 'Gerrit gates via the Verified LABEL (not a required "check") — one-time setup:',
190
+ '',
191
+ ' 1. Commit .zuul.yaml + playbooks/yaylayer-gate.yaml so your CI runs `yay verify --strict`',
192
+ ' on every change (refs/for/*) — Zuul, or Jenkins via the Gerrit Trigger plugin.',
193
+ ' 2. Make sure the CI account votes the Verified label from the result (+1 pass, -1 fail).',
194
+ ' 3. Add a SUBMIT REQUIREMENT on the project: require Verified=MAX (and Code-Review=+2) to submit',
195
+ ' (Browse → Repositories → your repo → Submit requirements, or project.config).',
196
+ ' 4. Block direct pushes to refs/heads/main so every change goes through review (refs/for/main).',
197
+ '', common,
198
+ ].join('\n');
199
+ if (p === 'gitea') return [
200
+ 'To make it enforce (one-time, in Gitea/Forgejo):',
201
+ '',
202
+ ' 1. Commit .gitea/workflows/yaylayer.yml (enable Actions for the repo) and let it run once.',
203
+ ' 2. Settings → Branches → Branch protection → add a rule for main.',
204
+ ' 3. Enable "Require status checks" → add the "gate" check; require a PR + approvals.',
205
+ '', common,
206
+ ].join('\n');
207
+ // github (default)
208
+ return [
209
+ 'To make it enforce, require the check on main (one-time, in the browser):',
210
+ '',
211
+ ' 1. Push this workflow to main FIRST (while main is still unprotected), and let',
212
+ ' it run once — the "gate" check only becomes selectable after it has run.',
213
+ ' 2. GitHub → your repo → Settings → Rules → Rulesets → New ruleset → New branch ruleset.',
214
+ ' 3. Name it (e.g. "main gate"); Enforcement: Active.',
215
+ ' 4. Target branches → Add target → Include default branch (main).',
216
+ ' 5. Tick: Require a pull request before merging.',
217
+ ' 6. Tick: Require status checks to pass → search & select gate .',
218
+ ' 7. Tick: Block force pushes. Then Create.',
219
+ '', common,
220
+ ].join('\n');
221
+ }
222
+
223
+ // Write the pipeline file(s) for the chosen platform. Idempotent: skips an existing file unless force.
224
+ // Some platforms (gerrit) need a companion file (a Zuul playbook) — those come back in `extra`.
225
+ function writeWorkflow(root, opts = {}) {
226
+ const plat = PLATFORMS[normPlatform(opts.platform)] || PLATFORMS.github;
227
+ const writeOne = (rel, gen) => {
228
+ const abs = path.join(root, rel);
229
+ const existed = fs.existsSync(abs);
230
+ if (existed && !opts.force) return { action: 'skipped', path: rel };
231
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
232
+ fs.writeFileSync(abs, gen(opts));
233
+ return { action: existed ? 'overwritten' : 'created', path: rel };
234
+ };
235
+ const main = writeOne(plat.rel, plat.workflow);
236
+ const extra = (plat.extra || []).map((ex) => writeOne(ex.rel, ex.gen));
237
+ return { action: main.action, path: main.path, extra };
238
+ }
239
+
240
+ // Install the pre-push hook. Idempotent unless force; needs a git repo.
241
+ function writeHook(root, opts = {}) {
242
+ const dir = path.join(root, '.git', 'hooks');
243
+ if (!fs.existsSync(path.join(root, '.git'))) return { action: 'no-git', path: '.git/hooks/pre-push' };
244
+ const abs = path.join(dir, 'pre-push');
245
+ if (fs.existsSync(abs) && !opts.force) return { action: 'skipped', path: '.git/hooks/pre-push' };
246
+ fs.mkdirSync(dir, { recursive: true });
247
+ fs.writeFileSync(abs, prePushHook(opts));
248
+ try { fs.chmodSync(abs, 0o755); } catch (_) {}
249
+ return { action: 'created', path: '.git/hooks/pre-push' };
250
+ }
251
+
252
+ module.exports = { ciWorkflow, prePushHook, branchProtectionSteps, writeWorkflow, writeHook, WORKFLOW_REL, PLATFORMS, PLATFORM_KEYS, normPlatform };
package/src/grants.js ADDED
@@ -0,0 +1,249 @@
1
+ 'use strict';
2
+ // Autopilot — scoped, owner-signed delegation GRANTS, expanded in P3 into signed CAPABILITY
3
+ // ENVELOPES with attenuating CHILD GRANTS.
4
+ //
5
+ // A grant lets the machine AUTO-APPROVE in-scope, non-sensitive Cells for a bounded window
6
+ // (time + count + path/cell/risk envelope) without contacting the phone. It binds a machine-held
7
+ // grant PUBLIC key (the laptop keeps the matching private key and signs auto-approvals with it).
8
+ // The owner signs the grant on their phone (or local key) — the AI can never issue one. `yay verify`
9
+ // accepts an auto-approval only when a valid, unexpired, unrevoked, in-count, in-envelope grant
10
+ // covers it; the Cell is then GREEN-on-verify but marked AUTO on the TRUST axis and queued for
11
+ // human ratification.
12
+ //
13
+ // ENVELOPE (P3): { allow:[globs], deny:[globs], cells:[ids], maxCells, maxRisk, deps, deployment,
14
+ // childGrants:{allowed,maxDepth} }. Scope constraints (allow/deny/cells/maxCells/
15
+ // maxRisk) are enforced NOW — we know where a change lands. Content constraints
16
+ // (deps/deployment/network) are DETECTOR-GATED (D15): recorded + shown, enforced
17
+ // only where a detector exists, so we never promise a boundary we can't verify.
18
+ //
19
+ // CHILD GRANTS (P3): a grant may carry `parent` + be signed by the parent's GRANT key (not an
20
+ // owner). It can only ATTENUATE — allow ⊆ parent.allow, deny ⊇ parent.deny, maxCount ≤ parent
21
+ // remaining, expiry ≤ parent, risk ≤ parent, depth ≤ parent.childGrants.maxDepth. The verifier
22
+ // REFUSES any child that exceeds its parent, so no new authority is ever minted below the human
23
+ // root. Off by default (parent must set childGrants.allowed).
24
+ //
25
+ // A grant/revoke is a signed event (same body-canonicalisation as roster.eventBytes), stored
26
+ // append-only in .yaylayer/grants.json.
27
+ const C = require('./crypto');
28
+ const roster = require('./roster');
29
+ const { nonDelegable, globToRe, cellTags } = require('./policy');
30
+
31
+ function safeVerify(msg, sig, pub) { try { return !!(sig && pub && C.verify(msg, sig, pub)); } catch (_) { return false; } }
32
+
33
+ const RISK = { low: 1, medium: 2, high: 3 };
34
+ function riskRank(r) { return RISK[String(r || 'medium').toLowerCase()] || RISK.medium; }
35
+
36
+ // Normalize a grant event's envelope, tolerating the legacy shape (`scope.cells` + `maxCount`).
37
+ function envelopeOf(grant) {
38
+ const g = grant || {};
39
+ const env = g.envelope || {};
40
+ const legacyCells = (g.scope && Array.isArray(g.scope.cells)) ? g.scope.cells : null;
41
+ const cg = env.childGrants || {};
42
+ return {
43
+ allow: Array.isArray(env.allow) ? env.allow : [],
44
+ deny: Array.isArray(env.deny) ? env.deny : [],
45
+ allowTags: Array.isArray(env.allowTags) ? env.allowTags.map((t) => String(t).toLowerCase()) : [],
46
+ denyTags: Array.isArray(env.denyTags) ? env.denyTags.map((t) => String(t).toLowerCase()) : [],
47
+ guard: env.guard !== false, // default security guard ON unless explicitly lifted
48
+ cells: Array.isArray(env.cells) ? env.cells : (legacyCells || []),
49
+ maxRisk: env.maxRisk || null,
50
+ deps: env.deps || null, // detector-gated (recorded)
51
+ deployment: env.deployment || null, // detector-gated (recorded)
52
+ childGrants: { allowed: !!cg.allowed, maxDepth: cg.maxDepth != null ? cg.maxDepth : (cg.allowed ? 1 : 0) },
53
+ };
54
+ }
55
+
56
+ function pathMatchesAny(globs, file) {
57
+ if (!globs || !globs.length) return null; // no constraint
58
+ return globs.some((g) => { try { return globToRe(g).test(file || ''); } catch (_) { return false; } });
59
+ }
60
+
61
+ // A Cell is SENSITIVE — never auto-approvable — if it is code-pinned or marked sensitive in its
62
+ // spec. This is the COOPERATIVE signal (AI-writable, so an agent could strip it — visible in the
63
+ // ratification spec-diff). The AUTHORITATIVE backstop is the owner-signed policy `delegable:false`
64
+ // (see grantCoversCell's `nonDelegable` check), which lives outside the delegated surface.
65
+ function isSensitive(cell) {
66
+ if (!cell) return true;
67
+ const sp = cell.spec || {};
68
+ if (cell.codePin || sp.codePin || sp['code-pin'] || sp['code_pin']) return true;
69
+ const s = sp.sensitive;
70
+ return s === true || s === 'yes' || s === 'true';
71
+ }
72
+
73
+ // ── Default security guard ────────────────────────────────────────────────────
74
+ // A grant refuses to auto-approve high-stakes areas — auth, payments, secrets, deploy,
75
+ // CI/infra — UNLESS the human explicitly lifts the guard when issuing the grant
76
+ // (`--no-guard` → envelope.guard === false). Matched on the Cell's file path OR its tags,
77
+ // so it catches the usual layouts without any per-project setup. It's a DEFAULT DENY layered
78
+ // on top of the human's own allow/deny/tag scope: freedom by default, but never over the
79
+ // dangerous surface unless the human says so. Patterns live in the (trusted) verifier so the
80
+ // guard improves for existing grants; the signed envelope records only the on/off intent.
81
+ // "auth" alone is deliberately excluded so it doesn't snag "author"; the stems below do.
82
+ const GUARD_PATH = /(authentic|authoriz|oauth|login|signin|\bsession|password|passwd|credential|permission|rbac|secret|apikey|api[-_]?key|\btoken|\.env|keystore|private[-_]?key|payment|billing|checkout|invoice|stripe|paypal|subscription|\bcharge|deploy|release|migrat|terraform|\.tf(\b|$)|dockerfile|docker-compose|kubernet|\bk8s\b|helm|[\\/]infra|\.github[\\/]|\.gitlab|workflow|pipeline|jenkins|circleci|[\\/]ci[\\/])/i;
83
+ const GUARD_TAGS = new Set(['auth', 'authentication', 'authorization', 'security', 'payment', 'payments', 'billing', 'secret', 'secrets', 'deploy', 'deployment', 'release', 'ci', 'cd', 'infra', 'infrastructure']);
84
+ // Does a Cell fall under the default security guard (auth/payments/secrets/deploy/CI)?
85
+ function matchesGuard(cell) {
86
+ if (!cell) return false;
87
+ if (GUARD_PATH.test(String(cell.file || ''))) return true;
88
+ try { return cellTags(cell).some((t) => GUARD_TAGS.has(t)); } catch (_) { return false; }
89
+ }
90
+
91
+ // The risk level a Cell declares (`risk: low|medium|high`), defaulting to medium when unstated.
92
+ function cellRisk(cell) { return String(((cell && cell.spec) || {}).risk || 'medium').toLowerCase(); }
93
+
94
+ // Does a grant's envelope cover this Cell? opts.policy (owner-signed) supplies the authoritative
95
+ // non-delegable backstop. Returns a { ok, reason } so the verifier can explain a refusal.
96
+ function grantCoversCellR(grant, cellId, cell, opts) {
97
+ if (!grant) return { ok: false, reason: 'no grant' };
98
+ const policy = (opts && opts.policy) || { rules: [] };
99
+ if (nonDelegable(policy, cell || { file: '', spec: {} })) return { ok: false, reason: 'owner-signed policy marks this area non-delegable — needs a real human signature' };
100
+ if (isSensitive(cell)) return { ok: false, reason: 'Cell is sensitive/code-pinned — needs a real human signature' };
101
+ const env = envelopeOf(grant);
102
+ if (env.guard && matchesGuard(cell)) return { ok: false, reason: `matches the grant's default security guard (auth/payments/secrets/deploy/CI) — needs a real human signature (issue the grant with --no-guard to lift)` };
103
+ if (env.cells.length && !env.cells.includes(cellId)) return { ok: false, reason: 'Cell is not in the grant cell allow-list' };
104
+ const file = (cell && cell.file) || '';
105
+ if (env.deny.length && pathMatchesAny(env.deny, file)) return { ok: false, reason: `path is in the grant's deny list` };
106
+ const allowM = pathMatchesAny(env.allow, file);
107
+ if (allowM === false) return { ok: false, reason: `path is outside the grant's allowed paths` };
108
+ if (env.denyTags.length || env.allowTags.length) {
109
+ let tags = []; try { tags = cellTags(cell); } catch (_) { tags = []; }
110
+ if (env.denyTags.length && tags.some((t) => env.denyTags.includes(t))) return { ok: false, reason: `Cell tag is in the grant's deny-tags` };
111
+ if (env.allowTags.length && !tags.some((t) => env.allowTags.includes(t))) return { ok: false, reason: `Cell tag is outside the grant's allow-tags` };
112
+ }
113
+ if (env.maxRisk && riskRank(cellRisk(cell)) > riskRank(env.maxRisk)) return { ok: false, reason: `Cell risk (${cellRisk(cell)}) exceeds the grant's max risk (${env.maxRisk})` };
114
+ return { ok: true };
115
+ }
116
+ // Boolean convenience (back-compat with existing callers).
117
+ function grantCoversCell(grant, cellId, cell, opts) { return grantCoversCellR(grant, cellId, cell, opts).ok; }
118
+
119
+ // ── child-grant attenuation ────────────────────────────────────────────────────
120
+ // Every constraint of a child must be ⊆ its parent. Returns { ok, reason }.
121
+ function attenuates(child, parent) {
122
+ const c = envelopeOf(child), p = envelopeOf(parent);
123
+ // allow: every child allow path must be inside SOME parent allow (or parent unrestricted).
124
+ if (p.allow.length) {
125
+ if (!c.allow.length) return { ok: false, reason: 'child must restrict allowed paths (parent is path-scoped)' };
126
+ for (const a of c.allow) { if (pathMatchesAny(p.allow, sampleOf(a)) === false) return { ok: false, reason: `child allow "${a}" is outside parent allow` }; }
127
+ }
128
+ // deny: child must inherit (⊇) every parent deny.
129
+ for (const d of p.deny) { if (!c.deny.includes(d)) return { ok: false, reason: `child must keep parent deny "${d}"` }; }
130
+ // security guard: a child may never LIFT a guard the parent kept on.
131
+ if (p.guard && !c.guard) return { ok: false, reason: 'child cannot lift the parent grant\'s security guard' };
132
+ // deny-tags: child must inherit (⊇) every parent deny-tag.
133
+ for (const t of p.denyTags) { if (!c.denyTags.includes(t)) return { ok: false, reason: `child must keep parent deny-tag "${t}"` }; }
134
+ // allow-tags: if the parent scoped to tags, the child's allow-tags must be a subset.
135
+ if (p.allowTags.length) { for (const t of c.allowTags) if (!p.allowTags.includes(t)) return { ok: false, reason: `child allow-tag "${t}" is outside parent allow-tags` }; }
136
+ // cells: if parent restricts to a cell list, child's must be a subset.
137
+ if (p.cells.length) { for (const id of c.cells) if (!p.cells.includes(id)) return { ok: false, reason: `child cell "${id}" is outside parent cells` }; }
138
+ // maxCount: child ≤ parent remaining.
139
+ if (parent.maxCount != null && (child.maxCount == null || child.maxCount > parent.maxCount)) return { ok: false, reason: 'child maxCount exceeds parent' };
140
+ // expiry: child ≤ parent.
141
+ if (parent.expiresAt && (!child.expiresAt || Date.parse(child.expiresAt) > Date.parse(parent.expiresAt))) return { ok: false, reason: 'child expiry is later than parent' };
142
+ // risk: child ≤ parent.
143
+ if (p.maxRisk && riskRank(c.maxRisk || 'high') > riskRank(p.maxRisk)) return { ok: false, reason: 'child max risk exceeds parent' };
144
+ // depth: parent must allow children.
145
+ if (!p.childGrants.allowed) return { ok: false, reason: 'parent does not permit child grants' };
146
+ return { ok: true };
147
+ }
148
+ // A representative concrete path for a child allow-glob, so we can test containment against the
149
+ // parent's globs (e.g. child "src/ui/**" → sample "src/ui/x" which parent "src/**" must match).
150
+ function sampleOf(glob) { return String(glob).replace(/\*\*/g, 'x').replace(/\*/g, 'x'); }
151
+
152
+ // Validate + summarise every grant in the append-only log against the owner keys, the clock,
153
+ // revocations, the auto-approvals spent, AND (P3) the parent chain for child grants.
154
+ function deriveGrants(glog, ownerPubs, lockApprovals, nowMs) {
155
+ const now = nowMs || Date.now();
156
+ const events = (glog && glog.events) || [];
157
+ const revokeAt = {};
158
+ for (const e of events) if (e.type === 'grant-revoke' && e.grant) revokeAt[e.grant] = e.at;
159
+ const autos = {};
160
+ for (const a of (lockApprovals || [])) if (a.autoApproved && a.grant) (autos[a.grant] = autos[a.grant] || []).push(a);
161
+ for (const g of Object.keys(autos)) autos[g].sort((x, y) => String(x.at).localeCompare(String(y.at)));
162
+
163
+ const grantEvents = events.filter((e) => e.type === 'grant');
164
+ const byId = {};
165
+ for (const e of grantEvents) byId[e.id] = e;
166
+ const out = {};
167
+
168
+ // Depth-ordered pass: a child validates against its (already-derived) parent. Parents have no
169
+ // `parent` field so they resolve first; children resolve once their parent is in `out`.
170
+ const pending = grantEvents.slice();
171
+ let guard = pending.length + 1;
172
+ while (pending.length && guard-- > 0) {
173
+ const still = [];
174
+ for (const e of pending) {
175
+ if (e.parent && !out[e.parent]) { still.push(e); continue; } // parent not derived yet
176
+ out[e.id] = deriveOne(e, out, ownerPubs, revokeAt, autos, now);
177
+ }
178
+ if (still.length === pending.length) { // unresolved parents (missing/cyclic) — mark invalid
179
+ for (const e of still) out[e.id] = deriveOne(e, out, ownerPubs, revokeAt, autos, now);
180
+ break;
181
+ }
182
+ pending.length = 0; pending.push(...still);
183
+ }
184
+ return out;
185
+ }
186
+
187
+ function deriveOne(e, out, ownerPubs, revokeAt, autos, now) {
188
+ const rAt = revokeAt[e.id] || null;
189
+ const spent = (autos[e.id] || []).length;
190
+ const expired = !!(e.expiresAt && Date.parse(e.expiresAt) <= now);
191
+ let ownerOk = false, chain = null, attenuation = null;
192
+ if (e.parent) {
193
+ // Child: authority DESCENDS from the parent. It must be signed by the parent's grant key
194
+ // (issuedBy), the parent must be valid, and the child must strictly attenuate.
195
+ const parent = out[e.parent];
196
+ const parentValid = !!(parent && parent.ownerOk && !parent.revoked);
197
+ const sigOk = !!(parent && safeVerify(roster.eventBytes(e), e.signature, parent.grantPub) && (!e.issuedBy || e.issuedBy === parent.grantPub));
198
+ attenuation = parent ? attenuates(e, parent) : { ok: false, reason: 'parent grant not found' };
199
+ ownerOk = parentValid && sigOk && attenuation.ok;
200
+ chain = { parent: e.parent, parentValid, sigOk, attenuates: attenuation.ok, reason: attenuation.ok ? null : (sigOk ? attenuation.reason : 'child not signed by the parent grant key') };
201
+ } else {
202
+ // Root grant: must be validly OWNER-signed.
203
+ ownerOk = !!(e.signature && (ownerPubs || []).some((pub) => safeVerify(roster.eventBytes(e), e.signature, pub)));
204
+ }
205
+ // A parent's revocation cascades: a child is inactive once its parent is revoked/expired.
206
+ let parentInactive = false;
207
+ if (e.parent) { const pr = out[e.parent]; parentInactive = !pr || !pr.active; }
208
+ const active = ownerOk && !expired && !rAt && !parentInactive && (!e.maxCount || spent < e.maxCount);
209
+ return {
210
+ ...e, ownerOk, revokeAt: rAt, revoked: !!rAt, expired, spent,
211
+ remaining: e.maxCount ? Math.max(0, e.maxCount - spent) : null,
212
+ active, autos: autos[e.id] || [], envelope: envelopeOf(e), chain,
213
+ };
214
+ }
215
+
216
+ // The single active grant to auto-sign with right now (most recently issued that is active), or
217
+ // null. `cells` (id → cell) lets us confirm all targets are in scope. opts.policy = owner-signed.
218
+ function activeGrantFor(grants, targetIds, cells, opts) {
219
+ const list = Object.values(grants || {}).filter((g) => g.active)
220
+ .sort((a, b) => String(b.at).localeCompare(String(a.at)));
221
+ for (const g of list) {
222
+ if (g.maxCount && g.spent + targetIds.length > g.maxCount) continue; // would blow the count
223
+ if (targetIds.every((id) => grantCoversCell(g, id, cells[id], opts))) return g;
224
+ }
225
+ return null;
226
+ }
227
+
228
+ // Is THIS auto-approval of `cellId` acceptable under its grant? (verify does the actual signature
229
+ // check with grant.grantPub; here we check the grant + window + envelope + count.)
230
+ function autoApprovalOk(grant, approval, cellId, cell, opts) {
231
+ if (!grant) return { ok: false, reason: 'no such grant' };
232
+ if (!grant.ownerOk) return { ok: false, reason: grant.parent ? 'child grant does not validly chain to the owner root' : 'grant is not validly owner-signed' };
233
+ const at = Date.parse(approval.at) || 0;
234
+ if (Date.parse(grant.at) && at < Date.parse(grant.at)) return { ok: false, reason: 'approval predates the grant' };
235
+ if (grant.expiresAt && at > Date.parse(grant.expiresAt)) return { ok: false, reason: 'grant had expired' };
236
+ if (grant.revokeAt && at > Date.parse(grant.revokeAt)) return { ok: false, reason: 'grant was revoked before this approval' };
237
+ const cov = grantCoversCellR(grant, cellId, cell, opts);
238
+ if (!cov.ok) return { ok: false, reason: cov.reason };
239
+ if (grant.maxCount) {
240
+ const idx = (grant.autos || []).findIndex((a) => a.id === approval.id);
241
+ if (idx >= 0 && idx >= grant.maxCount) return { ok: false, reason: 'grant count exceeded' };
242
+ }
243
+ return { ok: true };
244
+ }
245
+
246
+ module.exports = {
247
+ isSensitive, matchesGuard, cellRisk, riskRank, envelopeOf, grantCoversCell, grantCoversCellR,
248
+ attenuates, deriveGrants, activeGrantFor, autoApprovalOk,
249
+ };
package/src/history.js ADDED
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ // Reconstruct a Cell's spec + code AS IT WAS when a given Brief signed it, straight from git —
3
+ // using the Brief's stored specHash as an exact, self-verifying anchor. Powers the Briefs-tab
4
+ // "As signed / Current / What changed" view: a Brief is a point-in-time record, so opening a Cell
5
+ // *through* it should show the version that was authorized, not today's. Read-only; needs git.
6
+ const path = require('path');
7
+ const cp = require('child_process');
8
+ const { MARK_BEGIN, MARK_END, langOf } = require('./util');
9
+ const { sha256 } = require('./crypto');
10
+ const { parseSpec, grabUnitBody } = require('./extract');
11
+
12
+ function git(dir, args) {
13
+ return cp.execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
14
+ }
15
+ function prefixBase(absFile) {
16
+ const dir = path.dirname(absFile), base = path.basename(absFile);
17
+ const prefix = git(dir, ['rev-parse', '--show-prefix']).trim(); // repo-root → file's dir (survives symlinked toplevels)
18
+ return { dir, rel: prefix + base };
19
+ }
20
+ function showAt(dir, rel, commit) {
21
+ try { return git(dir, ['show', commit + ':' + rel]); } catch (_) { return null; }
22
+ }
23
+ // The normalized marker-block for a Cell in raw content — byte-identical to extract.js, so
24
+ // sha256(it) equals the stored specHash. Returns null if the Cell isn't present in that version.
25
+ function normalizedBlock(content, cellId) {
26
+ const lines = String(content).split(/\r?\n/);
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const b = lines[i].match(MARK_BEGIN);
29
+ if (b && b[1] === cellId) {
30
+ const block = [lines[i]];
31
+ for (let j = i + 1; j < lines.length; j++) { block.push(lines[j]); if (MARK_END.test(lines[j])) return block.map((l) => l.replace(/\s+$/, '')).join('\n'); }
32
+ return null;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+ // Extract a Cell's spec block + code body from raw content at a given language family.
38
+ function cellFromContent(content, cellId, family) {
39
+ const lines = String(content).split(/\r?\n/);
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const b = lines[i].match(MARK_BEGIN);
42
+ if (b && b[1] === cellId) {
43
+ const block = [lines[i]]; let end = -1;
44
+ for (let j = i + 1; j < lines.length; j++) { block.push(lines[j]); if (MARK_END.test(lines[j])) { end = j; break; } }
45
+ if (end === -1) return null;
46
+ const spec = parseSpec(block);
47
+ const unit = grabUnitBody(lines, end + 1, family);
48
+ return { spec, block: block.map((l) => l.replace(/\s+$/, '')).join('\n'), code: unit ? unit.body : null };
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ // Walk the file's history (newest→oldest, bounded) and return the version whose spec hashes to
54
+ // signedHash — the exact thing the Brief sealed. { found, commit, at, block, code } | { found:false }.
55
+ function cellAsSigned(root, file, cellId, signedHash, limit) {
56
+ const abs = path.resolve(root, file);
57
+ let dir, rel;
58
+ try { ({ dir, rel } = prefixBase(abs)); } catch (_) { return { found: false, reason: 'not a git repository' }; }
59
+ let commits;
60
+ try { commits = git(dir, ['log', '--format=%H %cI', '-n', String(limit || 200), '--', rel]).trim().split('\n').filter(Boolean); }
61
+ catch (_) { return { found: false, reason: 'no git history for this file' }; }
62
+ const family = langOf(abs);
63
+ for (const line of commits) {
64
+ const sp = line.indexOf(' ');
65
+ const commit = line.slice(0, sp), at = line.slice(sp + 1);
66
+ const content = showAt(dir, rel, commit);
67
+ if (content == null) continue;
68
+ const norm = normalizedBlock(content, cellId);
69
+ if (norm == null) continue;
70
+ if (sha256(norm) === signedHash) {
71
+ const cell = cellFromContent(content, cellId, family) || {};
72
+ return { found: true, commit: commit.slice(0, 10), at, block: cell.block || norm, code: cell.code || null };
73
+ }
74
+ }
75
+ return { found: false, reason: 'the signed version wasn’t found in this file’s git history' };
76
+ }
77
+ module.exports = { cellAsSigned, normalizedBlock, cellFromContent };
package/src/ids.js ADDED
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ // Cell-id scheme for distributed (multi-branch / multi-clone) work.
3
+ //
4
+ // The problem: a bare sequential counter (C-001, C-002…) assumes ONE writer — two branches mint
5
+ // the same ids and collide on merge. And the id is part of the signed seal (items[id]=specHash),
6
+ // so renumbering after the fact invalidates the seal. So we prevent collisions AT CREATION with a
7
+ // short, stable per-CONTRIBUTOR shard: ids look like C-<shard>-<n> (e.g. C-3f2a-7). Two clones get
8
+ // different shards → new cells from different people NEVER collide, no renumber, no re-sign.
9
+ //
10
+ // And numbers are MONOTONIC / never reused: `taken` includes every id that ever appeared in the
11
+ // ledger, so a deleted feature's id is retired forever — an id always means one cell, like a git
12
+ // commit hash. Deletions leave permanent gaps, which is correct for an audit trail.
13
+
14
+ const C = require('./crypto');
15
+
16
+ // A short shard derived from a seed — the signer/root key fingerprint when available (stable and
17
+ // identity-tied), else random. 4 hex chars ≈ 65k space, so two contributors practically never coincide.
18
+ function deriveShard(seed) {
19
+ const hex = C.sha256(String(seed == null ? ('r' + Date.now() + Math.random()) : seed));
20
+ return hex.slice(0, 4);
21
+ }
22
+
23
+ // Highest counter already used for `shard` across a set of ids (current cells ∪ ledger history).
24
+ function maxForShard(ids, shard) {
25
+ let mx = 0; const re = new RegExp('^C-' + String(shard).replace(/[^a-z0-9]/gi, '') + '-(\\d+)$');
26
+ const each = (id) => { const m = String(id).match(re); if (m) { const v = parseInt(m[1], 10); if (v > mx) mx = v; } };
27
+ if (ids && typeof ids.forEach === 'function') ids.forEach(each); else for (const id of (ids || [])) each(id);
28
+ return mx;
29
+ }
30
+
31
+ // The next monotonic, sharded id. `taken` (a Set) should include current cell ids AND all historical
32
+ // ledger ids, so a deleted-but-once-used id is never handed out again.
33
+ function nextCellId(shard, taken) {
34
+ let n = maxForShard(taken, shard) + 1, id;
35
+ do { id = 'C-' + shard + '-' + n; n++; } while (taken && taken.has && taken.has(id));
36
+ if (taken && taken.add) taken.add(id);
37
+ return id;
38
+ }
39
+
40
+ module.exports = { deriveShard, maxForShard, nextCellId };