Compare commits

..
17 changed files with 749 additions and 601 deletions

No files matched your search

-124
View File
@@ -1,124 +0,0 @@
# Row 217 (Case 2 C2-06): build notes
Brief: docs/plans/2026-10-20_case2-rows.md, section C2-06, at base 45dca69f6.
Builder: Launchpad until 2026-10-23 19:09Z. Then Mos moved coding to Codex seats (mos-1500, Q245) and
the row went to Beakley on beakley/row217.
## State at handoff
All code in this commit is UNTESTED. No node, no test and no mutant has run on it. No test file is written.
`packages/harness/src/runner.mjs` is not started.
Done, untested:
- `packages/library/src/limits.mjs` (new, pure): `REPLY_LIMITS`, `resolveReplyLimits(read)` gives
`{requireCitation, citationsPerReply, fallback, unreadable}`, `checkReplyLimits(value)` gives a clone or null.
An unreadable business file forces `requireCitation` true. A key falls back to its default when
`replyLimits` is not an object or the key holds an invalid value.
- `packages/library/src/guard.mjs` (new): `GUARD_CODES` in design 5.2's table order, `FIXED_TEXTS`,
`WEB_CITE_DEFINITIONS`, `REASON_MAX` (300), `GuardError`, `parseReply`, `checkReply`, `libraryReader`,
`lastCommits`, `guardReply`.
- `packages/library/src/index.mjs`: exports for both.
- `packages/business/src/business.mjs`: `replyLimits` is a known top key. The validated output doesn't carry it.
- `packages/business/src/organization.mjs`: the M11 fence `replyLimitsFence(old, document, actor)` compares
against `old.human`, refuses the tester and any `<definition>.<instance>` of `old.roles`, and is called for
both `document` and `written`. `setReplyLimits` and `readRaw` are exported.
- `packages/bus/src/broker.mjs`: the guard call, the page store, the check event, `library.limits.set`,
`library.status` gains `replyLimits`, `role.claim` gains `library`, `thread.messages` gains `check` and `last`.
- `packages/bus/src/server.mjs`, `client.mjs`: `BusError.reason` (sage-3599).
- `packages/bus/src/runtime.mjs`: guard deps and the `library.limits.set` dispatch.
- `packages/cli/src/limits.mjs`: `limits replies --file LIMITS`.
Left:
- `packages/harness/src/runner.mjs`: the C2-07 runner work (see "Runner plan" below).
- `packages/cli/README.md`: a line for `limits replies`.
- Every test in the brief and the rulings below, then the mutants.
- The rebases onto rows 283, 216 and 285, with the drift checks below.
- This file's gate results and the manifest.
## Rulings in force
sage-3595:
1. Supporting scope: a stub classify test now. Before in-review, rebase onto row 283's landing
(honker/row283-build bd2c1c4ea when ruled) and add two real-fixture tests: a supporting cite with
`as: "canon"` refuses `playground-cited-as-canon`, and the stored check result names `supporting`.
2. runtime.mjs is approved for the guard wiring and the one `library.limits.set` branch. List its hunks here,
name it in the manifest, and drift-check it after the 216 and 285 rebases.
3. The check result is an `action.allowed` event with `action: "message.checked"`. The events kind CHECK at
`packages/bus/src/migrations/3b.sql:9` has no `message.checked`, so a new kind would be a schema step.
The event and the message INSERT share one transaction: a mutant that splits them must fail.
4. readLibrary at the cited commit stands in for the C2-05a index. On the 216 rebase, compare 216's index
set with readLibrary's for the fixture and record the result here.
5. The retry is for a library business only. Tests: a JSON body between 32,768 characters and 64 KiB gets
the retry; outside a library business nothing changes.
6. Old `role.claim` pins stay as they are.
7. `render.mjs:6` and `render.test.mjs:58` belong to row 225. Leave them.
8. `thread.messages` rows that carry `check` get `last` per path cite: the file's last commit at the served
tip, one git call per distinct path per read. Test with a fixture file committed after the cite.
sage-3599: `BusError` gets an optional `reason`. Only the citation guard's refusals set it. The envelope
carries `reason` only when it is set. It is the guard's own text, never a quote, file text, url or a path
other than the cited one, at most 300 characters. Tests: a guard refusal reaches the runner with code and
reason; a refusal without a reason has an envelope byte-identical to today's, checked against an existing
pin; a mutant that drops the client.mjs pass-through fails.
sage-3602: store `file` per path cite from the same git log call as the trailer. Test with a cited commit
that didn't touch the file, so `file` differs from the cited commit, and `last` equals `file`.
## The check-result contract with row 225 (launchpad-0118, launchpad-0120, morgana-0200, sage-3596)
- Stored on the `message.checked` event: `{citations: [...]}` in body order.
- A path cite: `{scope, agent, file}`. `scope` is the classify scope. `agent` is the cite's file's last
commit's `Mosaic-Agent` trailer at the cited commit, or null. `file` is that commit's 40-hex id, or null.
- A url cite: `{scope: "web", agent: null}`.
- On `thread.messages`, each path cite gains `last`: the file's last commit at the served tip (40-hex or null).
The Console compares `last` with `file`.
- Rows without `check` are unchanged. No `changed` boolean. The webui passes rows through unchanged.
## Hunks outside the brief's file list
- `packages/bus/src/runtime.mjs`: imports `setReplyLimits`; the library deps gain `guard`, `replyLimits`
(organizationWriter `readRaw`, or `{readable: false}` with no organization), `last` and `GuardError`;
the organization dispatch gains `library.limits.set`.
- `packages/bus/src/server.mjs`: a `refusal(e, other)` helper above serve's doc comment builds every
BusError envelope and adds `reason` only when set. The synchronous request path accepts a Promise result
(the guard is async), with a 120 s timeout, and releases `own` when it settles.
- `packages/bus/src/client.mjs`: `new BusError(r.error, reason)` with `reason` a string cut at 300.
## Decisions and their tradeoffs
- The guard runs async outside the SQL transaction, then re-enters request() through `#guardPass`, so
message.send's insert and the check event stay one synchronous transaction.
- The guard runs before message.send's own checks, so a guard code can come before an authority code.
- The check event id is derived from the message id (a UUID-shaped sha256), so `thread.messages` finds it
through the unique index on `events.id`.
- With no organization configured, `replyLimits` reads as unreadable, so it fails closed.
- With more than one library project, the guard uses the first in the business file's order.
- A library business without guard deps refuses guarded sends with `project-not-configured`.
- The page store is per cap: 20 pages of 200 KB, kept only in a library business, dropped at session
delete or disconnect and at run end.
- The CLI `replies` verb reads the revision itself from `limits`.
- Guard reasons never carry a url or a quote (sage-3599).
## Runner plan (not built)
`role.claim`'s reply is discarded at `runner.mjs:462`; keep it and read `library === true`. In a library
business, at the reply site (`runner.mjs:582-587`):
- Fixed texts `(the session gave no answer)` and `(the turn failed)`; the reason stays in the run log.
A fixed text goes with no note, and `unslop.sent()` is not called, so the note waits.
- A body that parses as a JSON object with a string `text` gets `unslop.note()` appended to `text`, is
serialized again and is never clipped. A plain body keeps `clip(body) + unslop.note()`.
- On a refusal whose code is in `GUARD_CODES`, or `request-too-large` from the client, or the broker's
`invalid-request` from `string(a.body, 32768)` for a body over 32,768 characters (ruling 5; the runner
can tell it by the body length it sent), run one new turn in the same
session naming the code and `err.reason`, or the 64 KiB limit. Bracket it with usage like other turns.
- After the retry: a table code sends `(no answer passed the citation check)`, too large sends
`(the answer was too long to send)`. One retry per answer, whatever the cause.
- `runTurn`'s output buffer holds up to 4 * BODY_MAX characters, so a reply over 64 KiB can reach the send.
- Outside a library business nothing changes.
## Mutants planned
- The M11 writer check removed.
- `document.human` compared instead of `old.human`.
- The check event split from the insert transaction.
- The client.mjs reason pass-through dropped.
+92
View File
@@ -0,0 +1,92 @@
# Row 298 (#1804, Q228): the chief-of-staff grant scope
Owner Megavolt, reviewer Dewey. Assigned in sage-3584 (mos-1489), 2026-10-23 18:53Z.
Base: origin/refactor 45dca69f6. Branch: megavolt/row298.
Brief: docs/plans/2026-10-23_mvp-readiness-rows.md, "Brief: the chief-of-staff grant scope (Q228)".
Decision: DECISIONS Q228 (jason-master-restart DECISIONS.md:2278). It replaces Mos's Q190 reading.
## What exists at 45dca69f6 (broker.mjs)
- Raise: `#raiseGrant` (:2558) is chief-only (`grant-chief-only`) and binds a grant body per method:
gitea `#giteaGrantBinding` (:2665), host `#hostGrantBinding` (:2599), or host-enroll `enrollBinding`.
The decision goes through `#dispatch(..., 'decision.raise', ...)`, routed to the human.
- Approval: `#approvedGrant` (:1525) returns the binding only for a credential.mint routed to the
human and closed with `approvalChoice` by the business's human, via 'cli', or via 'console' with
the broker's own human.input (row 252).
- Start: decision.resolve (:5653-5659) runs `#approvedGrant` after the close and, once the transaction
commits, starts `#runEnrollment`, `#runHostGrant` or `#startGitea` with setImmediate.
- Spend and records: each executor calls `#consumeGrant` and writes the 236 records (grant.call,
grant.result, grant.step, grant.applied or grant.failed, gitea_grants or host_grants). Revoke
goes through grants.revoke and hosts.revoke, as before.
- Human-only settings: `#human(s)` (cli, outsideAgent) plus `#mayHuman`, as in veto.set (:4713).
The organization file is checked whole by business.mjs `checkLaunch`, and a bad file refuses
every read.
## Proposed design (waits on Sage's rulings, Q1-Q6 below)
D1. Storage. The scope gets its own key, `launch.grantScope`, in the organization file. The
whole-file check doesn't refuse on it. A separate reader, `grantScope(document)`, returns the
parsed scope or, when the key is missing or bad, the empty scope `{}`, plus a `fallback` flag.
A bad value can't break every organization read, and it falls back to "everything needs
Approve", which is the fail-closed direction. (Q1)
D2. Shape. `{ gitea?: { agents: [id], projects: [id] }, host?: { agents: [id], hosts: [id],
commands: [[argv prefix]] } }`. A request is in scope only if its method has an entry, its agent
is listed, and every target is listed: each gitea project, the host, and each command prefix
equal to a listed prefix. host-enroll is never in scope, because it adds a host to the registry.
(Q2)
D3. Writer. A new verb, `grants.scope.set { revision, scope }`, CLI-only for the human: `#human(s)`,
then `#mayHuman`, with the verb added to LIFECYCLE_HUMAN_VERBS. The tester, the reader and every
agent are refused. organization.set's ORGANIZATION_KEYS don't include it, so the chief's tool
never reaches it, the same way instances.set and veto.set stay human-only. The write validates
strictly and refuses a bad value; only the read falls back. Receipt: `grants.scope` with the old
and new sha256. `grants.scope` reads the value for the human, the tester and the chief. (Q3)
D4. In-scope raise. Inside the raise transaction, `#raiseGrant` checks the binding against the scope
and, if it's in scope, closes the new decision at once: op resolved, choice approvalChoice, by
`grant-scope`, via `scope`. It writes a `grant.scope.used` event carrying the scope sha256 and
revision. `#approvedGrant` accepts via 'scope' only when that event exists for the close's seq
and its sha256 matches the scope as read in the same transaction. After the commit, the same
setImmediate starts the same executor, so 236's spend, records and revoke are unchanged. The
decision is closed before any inbox read, so no inbox item ever shows. (Q4)
D5. Out-of-scope raise: unchanged. The decision stays open, routed to the human, for the Console
and CLI Approve.
D6. Executor recheck. At the spend, `#consumeGrant` reruns the scope check for a via-'scope'
approval. If the scope changed between raise and run and the grant is now out of scope, it
fails with `grant.failed scope-changed` and the grant is spent. (Q5)
## Open questions for Sage
Q1. Storage: `launch.grantScope` in the organization file with a lenient reader (D1), or a separate
file under the business directory?
Q2. Shape: is per-method agent, project, host and command lists right? Should host-enroll stay
out of scope always?
Q3. "A scope change needs Approve": is the human's CLI verb itself the Approve (D3)? Or must the
chief also be able to raise a scope change that Jason approves in the inbox (a new decision
action, more code)? I recommend D3 alone. Should Console get a scope page in this row? I
propose no.
Q4. Recording an in-scope grant as a broker close via 'scope' with a scope event (D4), versus a
decision that is never raised (a new table). D4 keeps 236's records and revoke unchanged.
Q5. Scope narrowed between raise and run: fail scope-changed (D6), or honor the raise-time check?
Q6. Chief enrollment ("Jason's Approve stays for the chief of staff's enrollment"): I read this
as the chief's own instance and credentials, which the human's instances.set and the
credential lifecycle already cover. So nothing new is needed in this row. Agree?
## Tests (access by brief; all on scratch stores, fake Gitea, fake host)
T1 agent, chief and tester callers of grants.scope.set are refused, and organization.set with
grantScope is refused. T2 a bad or missing grantScope falls back to empty and the grant waits.
T3 an in-scope gitea request issues with no open decision and no inbox item, and writes the 236
records. T4 the same for a host request. T5 out of scope by agent, by project, or by command
waits for Approve. T6 host-enroll always waits. T7 a scope change by the human's CLI writes a
receipt; a scope change by any other path is refused. T8 an in-scope grant is logged and revoked
with grants.revoke or hosts.revoke. T9 scope narrowed before run gives scope-changed.
## Mutants (brief)
M1 the scope check skipped (every chief raise auto-closes): killed by T5 and T6.
M2 the fallback skipped (a bad value is used as-is or throws): killed by T2.
M3 the `#human` check removed from grants.scope.set: killed by T1.
## Holds
Darkwing holds the slot: no push, no claim, no queue write, no node and no test run until Sage
says so. Q188: no agent creates a grant; tests use scratch stores only.
+108 -139
View File
@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -203,26 +203,15 @@ const canonical = (x) =>
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
export class BusError extends Error {
// C2-06 (sage-3599): only the citation guard's refusals carry a reason, and the envelope passes it on.
constructor(code, reason) {
constructor(code) {
super(code);
this.code = code;
if (reason !== undefined) this.reason = reason;
}
}
const fail = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
const PAGE_COUNT = 20;
const PAGE_BYTES = 200 * 1024;
// C2-06: the guard's check event takes an id derived from the message's, so a thread read finds it
// through the unique index on events.id (there is none on subject).
const checkEventId = (mid) => {
const h = createHash('sha256').update('message.checked:' + mid).digest('hex');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
};
const text = (x, max = 4096) => typeof x === 'string' && x.length > 0 && x.length <= max && !x.includes('\0');
const id = (x) => text(x, 160) && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]*$/.test(x);
function keys(x, allowed, required = []) {
@@ -301,6 +290,9 @@ export function writeGrantEvent(store, at, business, body, subject = null) {
);
return eid;
}
// Row 298 (#1804, Q228): the by of a close the chief's grant scope makes, with via 'scope'. No session
// closes as it, and #approvedGrant takes it only with the grant.scope.used record of that close.
const SCOPE_BY = 'grant-scope';
export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove', 'hosts.revoke', 'hosts.sweep']);
// Row 236 (#1742, #436b r5): the Gitea grant path's verbs. run and admin.* are the human's alone.
export const GRANTS_VERBS = Object.freeze([
@@ -400,11 +392,6 @@ export class Broker {
#credentialVerbs;
#project;
#library;
// C2-06 (design 5.2, B7): cap -> the session's kept web.fetch pages, in a library business only.
// In memory, dropped with the session.
#pages = new Map();
// C2-06: { s, check } while a guarded message.send or a thread.messages re-enters request().
#guardPass = null;
#usage;
#limits;
#harnessOn;
@@ -428,6 +415,7 @@ export class Broker {
#revokeUndo = [];
#testerOn;
#roleFile;
#grantScope;
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
#giteaGrant;
@@ -455,11 +443,12 @@ export class Broker {
#clock = 0;
#processing;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
if (grantScope !== null && typeof grantScope !== 'function') throw new TypeError('grantScope must be a function');
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
if (access !== null && typeof access !== 'function') throw new TypeError('access must be a function');
if (accessStamp !== null && typeof accessStamp !== 'function') throw new TypeError('accessStamp must be a function');
@@ -497,6 +486,10 @@ export class Broker {
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
// tester lifecycle call and at the take. Without it the broker's own copy decides.
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
// Row 298 (#1804, Q228): grantScope(business) is launch.grantScope as the organization file stands,
// unchecked; the bus runtime reads it live, so the human's write counts at the next raise. Without
// it the broker's own copy decides.
this.#grantScope = grantScope ?? ((business) => this.#business(business).launch?.grantScope);
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
this.#roleFile = roleFile;
@@ -1419,10 +1412,7 @@ export class Broker {
this.#launcherReleased.delete(key);
this.#accessRevoked.delete(key);
for (const [cap, s] of this.#sessions)
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
if (!s.human && !s.reader && s.business === record.business && s.run === record.run) this.#sessions.delete(cap);
return this.#store.transaction(() => {
const h = this.#own(session);
const released = h?.op === 'claim' && h.holder_run === session.run;
@@ -1504,7 +1494,6 @@ export class Broker {
}
disconnect(cap) {
this.#sessions.delete(cap);
this.#pages.delete(cap);
}
identity(cap) {
const s = this.#session(cap);
@@ -1547,12 +1536,67 @@ export class Broker {
const raise = this.#raised(business, decision);
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
const r = this.#closed(decision);
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null;
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice) return null;
// Row 298 (#1804, Q228, sage-3594 Q4): a scope close counts only with its own grant.scope.used,
// which only #scopeClose writes, under the grant actor.
if (r.via === 'scope') return r.by === SCOPE_BY && this.#scopeUsed(business, decision, r.seq, raise.grant) ? raise.grant : null;
if (r.by !== this.#business(business).human) return null;
// Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input,
// which only the broker writes (recordEvent reserves the kind).
if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null;
return raise.grant;
}
// Row 298: the grant.scope.used record of scope close seq. Gitea and host grants only.
#scopeUsed(business, decision, seq, grant) {
if (grant.method !== 'gitea' && grant.method !== 'host') return false;
return Boolean(this.#store.get(
`SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.scope.used'
AND json_extract(body,'$.grantDecision')=? AND json_extract(body,'$.decisionSeq')=? AND json_extract(body,'$.method')=? LIMIT 1`,
business, decision, seq, grant.method,
));
}
// Row 298: the scope the broker applies now. A missing key, a bad value or an unreadable file is the
// empty scope, which covers nothing, so every grant waits for the human (brief item 1).
#scopeNow(business) {
try {
return grantScopeOf(this.#grantScope(business)).scope;
} catch {
return grantScopeOf(undefined).scope;
}
}
// Row 298 (D4, sage-3594 Q2/Q4): after a gitea or host raise, in its transaction. When the raiser is
// launch.by, the decision went to the human and the grant is inside the scope, the broker closes it
// with the approval choice, via 'scope', writes grant.scope.used and starts the executor, as the
// human's Approve does. The spend, the records and the revoke are row 236's and 235's, unchanged.
// Anything else is left in the inbox. launch.by is a hand edit, so a chief-of-staff instance the
// tester or an instances.set adds never raises in scope (Q6).
#scopeClose(s, view) {
if (s.role !== this.#business(s.business).launch?.by) return view;
const d = this.#store.get('SELECT id,route_to FROM decisions WHERE business=? AND id=?', s.business, view.id);
if (d?.route_to !== 'human' || this.#closed(d.id)) return view;
const raise = this.#raised(s.business, d.id);
const scope = this.#scopeNow(s.business);
if (!raise?.grant || !grantInScope(scope, raise.grant)) return view;
const seq = Number(
this.#store.run(
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
d.id, this.#now(), 'resolved', SCOPE_BY, raise.approvalChoice, null, 'scope',
).lastInsertRowid,
);
this.#grantEvent(s.business, {
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: seq, method: raise.grant.method,
agent: raise.grant.agent, scopeSha256: sha256(JSON.stringify(scope)),
}, d.id);
// As the resolve's: each executor rechecks all of this itself, so a rollback starts nothing.
if (raise.grant.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
if (raise.grant.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
return this.#decisionView(this.#decision(s, d.id));
}
// Row 298 (D6, sage-3594 Q5): at the spend, a scope close needs its grant still inside the scope.
// A narrowed scope ends it scope-changed, and the human's Approve is the way back.
#scopeRecheck(business, decision, g) {
if (this.#closed(decision)?.via === 'scope' && !grantInScope(this.#scopeNow(business), g)) fail('scope-changed');
}
// Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice,
// no role or run, and a three-string owner subject.
#consoleInput(business, decision, r) {
@@ -1785,6 +1829,7 @@ export class Broker {
}
// r6 X2 step 1's checks after the spend, R4's binding again. Throws the code that ends the grant.
#hostGrantRecheck(business, decision, g) {
this.#scopeRecheck(business, decision, g);
if (!this.#hostGrant || !this.#hostKeys) fail('host-not-configured');
const definition = this.#hostDefinition(business, g.agent);
if (this.#hostGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
@@ -2585,7 +2630,7 @@ export class Broker {
if (!this.#giteaGrant) fail('gitea-not-configured');
const g = this.#giteaGrant.giteaGrantCheck(a.grant, this.#now().slice(0, 10));
return this.#store.transaction(() => {
const result = this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g));
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g)));
this.#secretCheck(result);
return result;
});
@@ -2594,7 +2639,7 @@ export class Broker {
if (a.grant?.method === 'host') {
const g = this.#hostGrant.hostGrantCheck(a.grant, this.#now().slice(0, 10));
return this.#store.transaction(() => {
const result = this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g));
const result = this.#scopeClose(s, this.#dispatch(s, 'decision.raise', a, null, this.#hostGrantBinding(s.business, g)));
this.#secretCheck(result);
return result;
});
@@ -2716,6 +2761,7 @@ export class Broker {
}
// Section 4 step 1's checks after the spend. Throws the code that ends the grant.
#giteaRecheck(business, decision, g) {
this.#scopeRecheck(business, decision, g);
const { b, role, definition } = this.#giteaRole(business, g.agent);
if (this.#giteaGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch');
if (this.#giteaGrant.identitySha256(role, b.projects) !== g.identitySha256) fail('decision-mismatch');
@@ -4686,19 +4732,12 @@ export class Broker {
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (LIBRARY_VERBS.includes(request.verb))
return this.#requestLibrary(s, { verb: request.verb, args: structuredClone(args) });
// C2-06 (design 5.2): an agent's message to the human in a library business passes the citation
// guard first, outside any transaction, and thread.messages adds each path cite's `last`. Both
// then re-enter request() for the synchronous path below.
if (this.#guardPass?.s !== s) {
if (request.verb === 'message.send' && this.#guarded(s, args.to))
return this.#guardedSend(cap, s, request, structuredClone(args));
if (request.verb === 'thread.messages' && this.#libraryProject(s.business) && this.#library?.last)
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -4744,12 +4783,6 @@ export class Broker {
keys(args, VETO_KEYS, VETO_KEYS);
this.#human(s);
this.#mayHuman(s, "veto.set", args.name);
}
// C2-06 M11: the human's replyLimits verb. The tester is refused by the gate above and an agent
// session by human-required; the writer's fence refuses any other actor.
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -4801,6 +4834,34 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 298 (#1804, Q228, sage-3594 Q3): the chief's grant scope, read and written by the human at the
// cli alone, and the write is the human's Approve of the change. #human is the one check, so an
// agent, the chief, the tester and a reader refuse human-required (the Console's cap never gets
// here). bindHuman binds only the business's human. The writer is the organization writer's
// grantScope; ORGANIZATION_KEYS doesn't name grantScope, so organization.set and limits.set can't
// write it. Outside the store transaction, as the organization branch.
#grantScopeVerb(s, verb, a) {
this.#human(s);
if (!this.#organization) fail('not-configured');
if (verb === 'grants.scope') keys(a, []);
else keys(a, ['revision', 'scope'], ['revision', 'scope']);
let result;
try {
result = this.#organization(s.business, verb, a, s.human);
} catch (e) {
this.#organizationFail(e, verb);
}
this.#secretCheck(result);
// The change's record: the human proof bindHuman checked, and the old and new scopes' sha256.
// The writer's changes.jsonl entry holds the values.
if (verb === 'grants.scope.set')
this.#store.transaction(() => this.#grantEvent(s.business, {
action: 'grant.scope', by: s.human, via: s.via, outsideAgent: s.outsideAgent === true,
oldSha256: sha256(JSON.stringify(result.old)), newSha256: sha256(JSON.stringify(result.scope)),
transaction: result.transaction ?? null,
}));
return result;
}
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
// secret check above has refused one the bus already holds, a refusal records its code alone, and
// nothing returned carries it. Outside the store transaction, as the organization branch.
@@ -5173,14 +5234,7 @@ export class Broker {
const context = { business: this.#business(s.business), project: args.project };
let result;
if (verb === 'library.file') result = await this.#library.readFile({ ...context, path: args.path });
else if (verb === 'library.status') {
result = await this.#library.status(context);
// C2-06: the reply limits in force, read from the file now, and the keys that fell back.
if (this.#library.replyLimits) {
const { requireCitation, citationsPerReply, fallback, unreadable } = this.#library.replyLimits(s.business);
result = { ...result, replyLimits: { requireCitation, citationsPerReply, fallback, problem: unreadable ? 'reply-limits-unreadable' : null } };
}
}
else if (verb === 'library.status') result = await this.#library.status(context);
else {
// A fetch that did not move served still answers with the code, so the refusal is recorded.
const { state, code } = await this.#library.sync(context, { accept: args.accept ?? null });
@@ -5194,78 +5248,6 @@ export class Broker {
throw this.#refused(s, Failure && error instanceof Failure ? new BusError(error.code) : error, request);
}
}
// C2-06: the business's library project, the first in file order, or null.
#libraryProject(business) {
return Object.entries(this.#business(business).projects).find(([, p]) => p.kind === 'library')?.[0] ?? null;
}
// C2-06 (design 5.2): every agent session's message to the human in a business with a library project
// is guarded, whatever the sender's definition.
#guarded(s, to) {
return to === 'human' && !s.human && !s.tester && !s.user && !s.reader && !s.console && this.#libraryProject(s.business) !== null;
}
#reenter(cap, s, request, check) {
this.#guardPass = { s, check };
try {
return this.request(cap, request);
} finally {
this.#guardPass = null;
}
}
// As #requestProject: the git reads run outside any SQL transaction. A guard refusal is recorded with
// its code and reaches the caller with the guard's reason; the send then runs, and stores the check
// result in its own transaction.
async #guardedSend(cap, s, request, args) {
let check;
try {
this.#store.transaction(() => this.#agent(s));
if (!this.#library?.guard || !this.#library?.replyLimits) fail('project-not-configured');
if (typeof args.body !== 'string') fail('invalid-request');
const business = this.#business(s.business);
check = await this.#library.guard({ business, project: this.#libraryProject(s.business) }, args.body, {
limits: this.#library.replyLimits(s.business),
definition: business.roles[s.role]?.definition ?? null,
pages: this.#pages.get(cap) ?? [],
});
} catch (e) {
const Guard = this.#library?.GuardError;
throw this.#refused(s, Guard && e instanceof Guard ? new BusError(e.code, e.reason) : e, request);
}
return this.#reenter(cap, s, request, check);
}
// C2-06 (sage-3595, morgana-0200): each path cite of a checked row gains `last`, the file's last commit
// at the served tip, one git call per distinct path. Rows without `check` are unchanged.
async #withLast(cap, s, request) {
const rows = this.#reenter(cap, s, request, null);
if (!rows.some((m) => m.check)) return rows;
const cites = (m) => {
try {
const c = JSON.parse(m.body).citations;
return Array.isArray(c) ? c : [];
} catch {
return [];
}
};
let last = new Map();
try {
const paths = rows.filter((m) => m.check).flatMap((m) => cites(m).filter((c) => typeof c?.path === 'string').map((c) => c.path));
last = await this.#library.last({ business: this.#business(s.business), project: this.#libraryProject(s.business) }, paths);
} catch {
last = new Map();
}
return rows.map((m) => {
if (!m.check) return m;
const body = cites(m);
return { ...m, check: { ...m.check, citations: m.check.citations.map((c, i) =>
c.scope === 'web' ? c : { ...c, last: last.get(body[i]?.path) ?? null }) } };
});
}
// C2-06 (B7): a fetch's text, kept for url cites in a library business only.
#keepPage(cap, s, url, page) {
if (!this.#sessions.has(cap) || !this.#libraryProject(s.business) || typeof page?.text !== 'string') return;
const text = Buffer.from(page.text).subarray(0, PAGE_BYTES).toString();
const kept = { url, finalUrl: typeof page.finalUrl === 'string' ? page.finalUrl : null, text };
this.#pages.set(cap, [...(this.#pages.get(cap) ?? []), kept].slice(-PAGE_COUNT));
}
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
async requestTask(cap, request, handler) {
@@ -5349,7 +5331,6 @@ export class Broker {
throw refuse(new BusError('web-failed'));
}
complete(out.summary);
if (verb === 'web.fetch') this.#keepPage(cap, s, args.url, out.result);
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
}
// Row 210 (#1707, design D5): host.run, one sudo command on a registered host over ssh, for a
@@ -5476,7 +5457,7 @@ export class Broker {
by,
);
this.#routeSession(s.session);
return { role: s.role, run: s.run, thread: s.thread, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
return { role: s.role, run: s.run, thread: s.thread };
}
if (
this.#store.get(
@@ -5500,8 +5481,7 @@ export class Broker {
by,
);
this.#routeWaiting(s.business, s.role);
// C2-06 (C2-07): the runner's switch to the library texts and retry. Absent elsewhere.
return { role: s.role, run: s.run, ...(this.#libraryProject(s.business) ? { library: true } : {}) };
return { role: s.role, run: s.run };
}
case 'role.release': {
keys(a, []);
@@ -5865,10 +5845,6 @@ export class Broker {
if (this.#userThread(s) && to === 'human') fail('user-thread-human');
this.#checkMessaging(s.business, this.#sender(s), testerThread && to === 'tester' ? 'human' : to);
string(a.body, 32768);
// C2-06: a guarded send is stored only through #guardedSend's pass, with its check result.
const guarded = this.#guarded(s, to);
if (guarded && this.#guardPass?.s !== s) fail('storage-refused');
const check = guarded ? this.#guardPass.check : null;
if (
a.class !== undefined &&
![
@@ -5915,9 +5891,6 @@ export class Broker {
a.corrects ?? null,
a.body,
);
// C2-06: the stored check result shares the message's transaction (sage-3595 item 3). No new
// event kind, which would be a schema step: the events kind CHECK is migrations/3b.sql:9.
if (check) this.#event(s, 'action.allowed', { action: 'message.checked', message: mid, check }, mid, checkEventId(mid));
const cancelled = this.#route(s.business, mid, to, thread);
const request = s.human
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
@@ -6537,15 +6510,11 @@ export class Broker {
)?.seq;
if (after === undefined) fail('message-not-found');
}
// C2-06 (row 225, morgana-0198): a row the citation guard checked carries the stored result.
return this.#store.all(
`SELECT m.* FROM messages m JOIN message_threads mt ON mt.message=m.id WHERE mt.session=? AND m.seq>? ORDER BY m.seq LIMIT ${limit}`,
session,
after,
).map((m) => {
const e = this.#store.get("SELECT body FROM events WHERE id=? AND kind='action.allowed' AND subject=?", checkEventId(m.id), m.id);
return e ? { ...m, check: JSON.parse(e.body).check } : m;
});
);
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
+1 -2
View File
@@ -41,8 +41,7 @@ export class Client {
const r = JSON.parse(input.toString('utf8'));
if (r.ok === true) end(null, r.result);
else if (r.ok === false && typeof r.error === 'string' && /^[a-z-]{1,64}$/.test(r.error))
// C2-06 (sage-3599): the citation guard's reason, when the envelope has one.
end(new BusError(r.error, typeof r.reason === 'string' ? r.reason.slice(0, 300) : undefined));
end(new BusError(r.error));
else end(new BusError('invalid-response'));
} catch {
end(new BusError('invalid-response'));
+11 -10
View File
@@ -14,7 +14,7 @@ import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../business/src/organization.mjs';
import { organizationWriter, OrganizationError } from '../../business/src/organization.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
@@ -305,13 +305,6 @@ export async function startBroker({
status: context => libraryModule.libraryStatus({ ...context, dataRoot }),
sync: (context, { accept }) => processing.work(() => libraryModule.sync({ ...context, dataRoot }, { ...syncOptions(context.business), accept })),
LibraryError: libraryModule.LibraryError,
// C2-06: the citation guard, the reply limits read from the business file at each check (none
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
project: {
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
@@ -330,6 +323,8 @@ export async function startBroker({
if(verb==='instances')return instances.read(id,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
// Row 298 (#1804, Q228): the chief's grant scope as the file stands, the fallback shown.
if(verb==='grants.scope')return instances.grantScopeRead(id);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
@@ -341,8 +336,8 @@ export async function startBroker({
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// Row 298: the human's grant scope, through the instance writer.
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
@@ -374,6 +369,12 @@ export async function startBroker({
return false;
}
},
// Row 298 (#1804, Q228): launch.grantScope as the file stands now, unchecked. The broker checks
// it; a throw here (an unreadable file) is the empty scope there.
grantScope: (id) => {
const b = settings ? settings.current(id) : businesses[id];
return b?.launch?.grantScope;
},
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
testerOn: (id) => {
try {
+5 -23
View File
@@ -7,11 +7,6 @@ const LIMIT = 65536;
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only the citation guard's refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -20,7 +15,6 @@ const refusal = (e, other) => e instanceof BusError
// host-not-configured.
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
// (row 252) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
try {
lstatSync(path);
@@ -102,7 +96,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestTask(cap, { verb: r.verb, args: r.args ?? {} }, tasks.handle)
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'adapter-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'adapter-failed' }),
)
.finally(() => {
if (own) release();
@@ -120,7 +114,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestWeb(cap, { verb: r.verb, args: r.args ?? {} }, service, { onAdmit: () => socket.setTimeout(service.timeout) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'web-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'web-failed' }),
)
.finally(() => {
if (own) release();
@@ -136,7 +130,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
.then(
(result) => finish({ ok: true, result }),
(e) => finish(refusal(e, 'host-failed')),
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
)
.finally(() => {
if (own) release();
@@ -159,26 +153,14 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
socket.setTimeout(120_000);
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
result => { if (!socket.destroyed) finish({ ok: true, result }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
).finally(() => { if (own) release(); });
return;
}
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
// C2-06: a guarded message.send and a library business's thread.messages read Git first, so
// the broker answers them with a promise. Every other verb stays synchronous.
if (result instanceof Promise) {
const own = transient;
transient = null;
socket.setTimeout(120_000);
result.then(
value => { if (!socket.destroyed) finish({ ok: true, result: value }); },
e => { if (!socket.destroyed) finish(refusal(e, 'invalid-envelope')); },
).finally(() => { if (own) release(); });
return;
}
finish({ ok: true, result });
} catch (e) {
finish(refusal(e, 'invalid-envelope'));
finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' });
} finally {
if (transient) release();
}
+264
View File
@@ -0,0 +1,264 @@
// Row 298 (#1804, Q228): the chief of staff's grant scope at the broker. The human's grants.scope and
// grants.scope.set (sage-3594 Q3), the in-scope close via 'scope' with grant.scope.used (Q4), the
// spend recheck's scope-changed (Q5), and the out-of-scope raise that still waits for the human.
// Gitea grants run against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1; host grants stop at the
// spend, as no host key store is configured, and nothing connects to a host. Every token is a fake the
// fake made and none is printed. The organization writer is a fake here; the real one is
// packages/business/tests/grant-scope.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
import { hostGrant } from '../src/host-grant.mjs';
import { Store } from '../src/store.mjs';
import { checkGrantScope, grantScopeOf } from '../../business/src/vocabulary.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hostRecord } from './host-fixtures.mjs';
const USER = 'demo-coder';
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sha256 = (x) => createHash('sha256').update(x).digest('hex');
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const DEFINITIONS = {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
ops: { roleVersion: 2, credentials: [{ service: 'host' }] },
};
const GITEA = { gitea: { agents: ['coder'], projects: ['app'] } };
const HOST = { host: { agents: ['ops'], hosts: ['web1'], commandPrefixes: [['systemctl', 'status']] } };
const REVISION = 'a'.repeat(64);
async function until(fn, ms = 10000) {
const end = Date.now() + ms;
while (!fn()) {
if (Date.now() > end) throw new Error('timed out');
await sleep(20);
}
}
const rejects = (p, code) => assert.rejects(Promise.resolve().then(p), (e) => (assert.equal(e.code, code), true));
// opts.scope: the value grantScope answers, or a function called at each read.
async function setup(t, opts = {}) {
const dir = mkdtempSync(join(tmpdir(), 'g298-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
gitea.addUser('acme');
gitea.addRepo('acme', 'app');
gitea.addRepo('acme', 'lib');
const configDir = join(dir, 'cfg');
mkdirSync(configDir, { mode: 0o700 });
const storeRoot = join(configDir, 'secrets', 'agents');
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const store = new Store(join(dir, 'data'));
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
t.after(() => {
credentials.close();
store.close();
});
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none },
// A second chief-of-staff instance, as an instances.set or the tester could add. It isn't launch.by.
chief2: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url: gitea.url, user: USER } }, publish: ['app', 'lib'] },
ops: { definition: 'ops', ...none },
},
definitions: structuredClone(DEFINITIONS),
projects: Object.fromEntries(['app', 'lib'].map((p) => [p, { remote: `${gitea.url}/acme/${p}` }])),
launch: { by: 'chief', instances: ['pm', 'coder', 'ops'] },
vars: { 'bus.tester': true },
};
const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: 5000 });
grantApi.writeAdmin('demo', gitea.adminToken, { url: gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), setAt: '2026-10-21T00:00:00.000Z' });
// The fake organization writer: the scope in a box, checked as the real writer checks it.
const box = { value: opts.scope };
const orgCalls = [];
const organization = (id, verb, args, actor) => {
orgCalls.push([verb, actor]);
if (verb === 'grants.scope') return { business: id, revision: REVISION, ...grantScopeOf(box.value) };
if (verb === 'grants.scope.set') {
const scope = checkGrantScope(args.scope);
if (scope === null) throw Object.assign(new Error('scope is not a grant scope'), { code: 'invalid-request' });
const old = grantScopeOf(box.value).scope;
box.value = scope;
return { business: id, scope, old, transaction: 'tx-298' };
}
return { revision: REVISION };
};
const broker = new Broker({
store,
businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value),
giteaGrant: grantApi,
giteaKeys: giteaKeys({ credentials, storeRoot }),
hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }),
organization,
grantScope: () => (typeof box.value === 'function' ? box.value() : box.value),
});
const caps = {};
for (const role of ['chief', 'chief2', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = broker.bindTester({ business: 'demo' });
caps.reader = broker.bindReader({ business: 'demo' });
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'https://idp.example.test', sub: 's' } });
hostRecord(store, { host: 'web1', entry: ENTRY });
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const raiseGitea = (over = {}, who = 'chief') =>
call(who, 'decision.raise', {
action: 'credential.mint', question: 'Grant coder Gitea?', options, recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30), ...over },
});
const raiseHost = (over = {}, who = 'chief') =>
call(who, 'decision.raise', {
action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false,
grant: { method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status', 'nginx']], rotateBy: dayFrom(30), ...over },
});
const grantEvents = () =>
store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action));
const closes = (decision) => store.all('SELECT * FROM decision_events WHERE decision=? ORDER BY seq', decision);
const spent = (decision) => grantEvents().filter((e) => e.decision === decision && e.operation?.startsWith('grant.')).map((e) => e.operation);
const record = () => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent='coder'");
return { store, broker, caps, box, orgCalls, call, raiseGitea, raiseHost, grantEvents, ended, closes, spent, record, gitea };
}
test('row 298 (Q3): only the human at the cli reads or sets the scope; agents, the tester, a reader and the Console refuse', async (t) => {
const f = await setup(t);
for (const verb of ['grants.scope', 'grants.scope.set']) {
const args = verb === 'grants.scope' ? {} : { revision: REVISION, scope: GITEA };
for (const who of ['chief', 'chief2', 'pm', 'tester']) await rejects(() => f.call(who, verb, args), 'human-required');
await rejects(() => f.call('reader', verb, args), 'read-only');
await rejects(() => f.call('console', verb, args), 'human-required');
}
assert.deepEqual(f.orgCalls, []);
assert.equal(f.box.value, undefined);
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope'), []);
});
test('row 298 (Q1): organization.set and limits.set refuse a grantScope key, and nothing is written', async (t) => {
const f = await setup(t);
await rejects(() => f.call('human', 'limits.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
await rejects(() => f.call('chief', 'organization.set', { revision: REVISION, grantScope: GITEA }), 'invalid-request');
assert.deepEqual(f.orgCalls, []);
assert.equal(f.box.value, undefined);
});
test('row 298 (Q3): the human verb writes the scope and a grant.scope record with the human proof and both sha256', async (t) => {
const f = await setup(t);
assert.deepEqual(await f.call('human', 'grants.scope'), { business: 'demo', revision: REVISION, scope: {}, fallback: false });
const r = await f.call('human', 'grants.scope.set', { revision: REVISION, scope: GITEA });
assert.deepEqual(r.scope, GITEA);
assert.deepEqual(f.orgCalls, [['grants.scope', 'jason'], ['grants.scope.set', 'jason']]);
const changes = f.grantEvents().filter((e) => e.action === 'grant.scope');
assert.deepEqual(changes, [{
action: 'grant.scope', by: 'jason', via: 'cli', outsideAgent: true,
oldSha256: sha256(JSON.stringify({})), newSha256: sha256(JSON.stringify(GITEA)), transaction: 'tx-298',
}]);
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: { gitea: {} } }), 'invalid-request');
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION }), 'invalid-request');
await rejects(() => f.call('human', 'grants.scope.set', { revision: REVISION, scope: {}, extra: 1 }), 'invalid-request');
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.scope').length, 1);
});
test('row 298 (Q4): an in-scope Gitea grant is issued with no inbox item, logged with 236 records, and revocable', async (t) => {
const f = await setup(t, { scope: GITEA });
const d = await f.raiseGitea();
const [close] = f.closes(d.id);
assert.deepEqual([close.op, close.by, close.choice, close.via], ['resolved', 'grant-scope', 'yes', 'scope']);
const inbox = await f.call('human', 'inbox');
assert.ok(!JSON.stringify(inbox).includes(d.id), 'the decision is not in the human inbox');
await until(() => f.ended(d.id));
await f.broker.giteaIdle();
assert.equal(f.ended(d.id).action, 'grant.applied');
const used = f.grantEvents().find((e) => e.action === 'grant.scope.used');
assert.deepEqual(used, {
action: 'grant.scope.used', grantDecision: d.id, decisionSeq: close.seq, method: 'gitea', agent: 'coder', scopeSha256: sha256(JSON.stringify(GITEA)),
});
assert.deepEqual(f.spent(d.id), ['grant.gitea']);
assert.equal(f.record().decision, d.id);
// The spend is row 236's: a second start finds it consumed.
assert.equal(await f.broker.giteaStart?.('demo', d.id) ?? 'decision-consumed', 'decision-consumed');
const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' });
assert.deepEqual(out.serviceLeft, []);
assert.ok(f.grantEvents().some((e) => e.action === 'grant.revoked' && e.agent === 'coder'));
assert.equal(f.record(), undefined);
});
test('row 298: an out-of-scope Gitea grant waits for the human, whose Approve still issues it', async (t) => {
const f = await setup(t, { scope: GITEA });
const d = await f.raiseGitea({ target: { projects: ['app', 'lib'] } });
assert.deepEqual(f.closes(d.id), []);
assert.ok(JSON.stringify(await f.call('human', 'inbox')).includes(d.id));
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
await f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
await until(() => f.ended(d.id));
await f.broker.giteaIdle();
assert.equal(f.ended(d.id).action, 'grant.applied');
});
test('row 298: with no scope, a bad scope or an unreadable file, an in-scope-looking grant waits', async (t) => {
const bad = { gitea: { agents: ['coder'], projects: ['app'], note: 'not a key' } };
for (const scope of [undefined, bad, () => { throw new Error('unreadable'); }]) {
const f = await setup(t, { scope });
const d = await f.raiseGitea();
assert.deepEqual(f.closes(d.id), [], String(scope));
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.scope.used'), []);
}
});
test('row 298 (Q6): a chief-of-staff instance that is not launch.by never raises in scope', async (t) => {
const f = await setup(t, { scope: GITEA });
const d = await f.raiseGitea({}, 'chief2');
assert.deepEqual(f.closes(d.id), []);
});
test('row 298 (Q5): a scope narrowed between the raise and the spend ends the grant scope-changed', async (t) => {
let reads = 0;
const f = await setup(t, { scope: () => (reads++ === 0 ? GITEA : {}) });
const d = await f.raiseGitea();
assert.equal(f.closes(d.id)[0].via, 'scope');
await until(() => f.ended(d.id));
await f.broker.giteaIdle();
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
assert.equal(f.record(), undefined);
assert.deepEqual(f.gitea.tokens(USER) ?? [], []);
});
test('row 298 (Q2): an in-scope host grant is spent with no Approve; ls never covers lsblk, nor a scope command extra arguments', async (t) => {
const scope = { host: { agents: ['ops'], hosts: ['web1'], commands: [['ls']], commandPrefixes: [['systemctl', 'status']] } };
const f = await setup(t, { scope });
const d = await f.raiseHost();
assert.equal(f.closes(d.id)[0].via, 'scope');
await until(() => f.ended(d.id));
// The spend is row 235's. No host key store is configured, so the recheck after it ends here.
assert.deepEqual(f.spent(d.id), ['grant.host']);
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'host-not-configured']);
for (const commands of [[['lsblk']], [['ls', '-l']], [['systemctl', 'restart']]]) {
const w = await f.raiseHost({ commands });
assert.deepEqual(f.closes(w.id), [], JSON.stringify(commands));
}
});
test('row 298 (Q5): a host scope narrowed before the spend ends scope-changed', async (t) => {
let reads = 0;
const f = await setup(t, { scope: () => (reads++ === 0 ? HOST : {}) });
const d = await f.raiseHost();
assert.equal(f.closes(d.id)[0].via, 'scope');
await until(() => f.ended(d.id));
assert.deepEqual([f.ended(d.id).action, f.ended(d.id).code], ['grant.failed', 'scope-changed']);
});
+5 -2
View File
@@ -21,7 +21,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -175,7 +175,7 @@ function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire", "grantScope"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -212,6 +212,9 @@ function checkLaunch(launch, roles, definitions, file) {
return {
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
// Row 298 (#1804, Q228): the chief's grant scope passes through unchecked. A bad value must not
// refuse the whole file; the broker's reader (grantScopeOf) falls back to the empty scope instead.
...(launch.grantScope !== undefined ? { grantScope: structuredClone(launch.grantScope) } : {}),
};
}
+29 -2
View File
@@ -14,11 +14,12 @@ import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN } from "./vocabulary.mjs";
import { CREATIVE_DEFINITIONS, END_PROTECTED, HOST_SERVICE, ID_PATTERN, checkGrantScope, grantScopeOf } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch", "tracker"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const SCOPE_KEYS = ["revision", "scope"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
@@ -302,6 +303,32 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 298 (#1804, Q228): the chief's grant scope as the file stands. scope is the one the broker
// applies, so a bad value reads as {} with fallback true.
function grantScopeRead(business) {
const { document, revision } = writer.read(business);
if (!document.launch) throw new OrganizationError("organization-launch-not-configured");
return { business, revision, ...grantScopeOf(document.launch.grantScope) };
}
// Row 298: args { revision, scope }. Human-only at the broker (grants.scope.set), and the only
// writer of launch.grantScope: SET_KEYS and the organization.set keys don't name it. The scope is
// checked strictly here; {} removes the key. old is what the broker applied before the write,
// the fallback included.
function grantScope(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SCOPE_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision)) throw new OrganizationError("invalid-request");
const scope = checkGrantScope(args.scope);
if (scope === null) throw new OrganizationError("invalid-request", "scope is not a grant scope");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
if (Object.keys(scope).length === 0) delete next.launch.grantScope;
else next.launch.grantScope = scope;
const before = grantScopeOf(old.launch.grantScope).scope;
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
@@ -419,5 +446,5 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
return { writer, read, set, checkDelete, checkFire, veto, hire, fire };
return { writer, read, set, checkDelete, checkFire, veto, hire, fire, grantScope, grantScopeRead };
}
+1 -44
View File
@@ -8,7 +8,6 @@ import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./business.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -81,33 +80,6 @@ function changes(old, next, path = "") {
}];
}
// C2-06 M11: replyLimits is the human's alone. Any setter that changes it refuses unless the actor is
// the business's human as the file stood before the write, and no agent or the tester under that
// name. Compared as M9 compares projects, on the document and again on what lands on disk.
function replyLimitsFence(old, document, actor) {
if (canonicalJson(document?.replyLimits) === canonicalJson(old.replyLimits)) return;
const agents = Object.entries(old.roles ?? {}).map(([instance, entry]) => `${entry?.definition}.${instance}`);
if (actor === old.human && actor !== "tester" && !agents.includes(actor)) return;
// The reason names each changed key and its value in force (the brief's M11).
const plain = v => v !== null && typeof v === "object" && !Array.isArray(v);
const before = old.replyLimits, after = document?.replyLimits;
const named = plain(before) && plain(after)
? [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(k => canonicalJson(before[k]) !== canonicalJson(after[k]))
.map(k => `replyLimits.${k} is ${canonicalJson(before[k]) ?? "unset"}`)
: [`replyLimits is ${canonicalJson(before) ?? "unset"}`];
fail("organization-reply-limits-human-only", `${named.join(", ").slice(0, 240)}; only the human changes it`);
}
// C2-06: library.limits.set, the human's verb. It replaces the whole replyLimits object; a key left out
// is its default. An unknown key or a value outside the table refuses invalid-request, and the write's
// M11 fence refuses any actor but the human.
export function setReplyLimits(writer, id, args, actor) {
const replyLimits = checkReplyLimits(args?.replyLimits);
if (replyLimits === null || typeof args?.revision !== "string") fail("invalid-request", "replyLimits takes requireCitation and citationsPerReply only");
const { document } = writer.read(id);
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
@@ -121,19 +93,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
return { document, revision: sha256(bytes) };
}
// C2-06: the citation guard reads replyLimits fresh on every check, from the raw file, so a business
// file that fails validation still yields its limits. { readable: false } when the file is missing,
// unreadable, not JSON or not an object.
function readRaw(id) {
try {
const document = JSON.parse(regular(businessFilePath(id, dir)));
return document !== null && typeof document === "object" && !Array.isArray(document)
? { readable: true, document } : { readable: false };
} catch {
return { readable: false };
}
}
function withLock(id, fn) {
const parent = join(dir, "businesses");
directory(evidence);
@@ -232,7 +191,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// mapping, including library playgrounds and hybrid universes.
if (canonicalJson(document?.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -240,7 +198,6 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const written = JSON.parse(bytes);
if (canonicalJson(written.projects) !== canonicalJson(old.projects))
fail('organization-projects-protected');
replyLimitsFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
const after = sha256(bytes);
const transaction = randomUUID();
@@ -288,5 +245,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, readRaw, write, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
}
+75
View File
@@ -299,3 +299,78 @@ export function covers(grant, { business, project, instance }, now, { level } =
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
return true;
}
// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope in the organization
// file. Only the human writes it (grants.scope.set). A Gitea or host grant the enrolled chief raises
// inside it is issued with no per-grant Approve; anything else waits for the human as before. The
// broker and the writer read these, and they live here because the session sandbox mounts this file
// (seat/src/sandbox.mjs RUNNER_FILES) and the runner loads broker.mjs.
//
// Shape: { gitea?: { agents, projects }, host?: { agents, hosts, commands?, commandPrefixes? } }.
// Each list is distinct and nonempty. A host entry needs commands or commandPrefixes. A commands
// entry covers a requested prefix equal to it, token for token. A commandPrefixes entry also covers a
// requested prefix that extends it with more whole tokens. No entry matches part of a token, so
// ["ls"] never covers ["lsblk"]. Enrollment (host-enroll) is never in scope. {} is the empty scope.
export const GRANT_SCOPE_EMPTY = Object.freeze({});
const SCOPE_IDS = (value) =>
Array.isArray(value) && value.length > 0 && value.length <= 256 &&
value.every((x) => typeof x === "string" && ID_PATTERN.test(x)) && new Set(value).size === value.length;
const SCOPE_ARGVS = (value) =>
Array.isArray(value) && value.length > 0 && value.length <= 256 && value.every(validArgv) &&
new Set(value.map((argv) => JSON.stringify(argv))).size === value.length;
const SCOPE_PLAIN = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
// The scope, rebuilt in a fixed key order so JSON.stringify of it is stable, or null when the value
// isn't one. The writer refuses null; the readers fall back (grantScopeOf).
export function checkGrantScope(value) {
if (!SCOPE_PLAIN(value) || Object.keys(value).some((k) => k !== "gitea" && k !== "host")) return null;
const out = {};
if (value.gitea !== undefined) {
const g = value.gitea;
if (!SCOPE_PLAIN(g) || Object.keys(g).some((k) => k !== "agents" && k !== "projects")) return null;
if (!SCOPE_IDS(g.agents) || !SCOPE_IDS(g.projects)) return null;
out.gitea = { agents: [...g.agents], projects: [...g.projects] };
}
if (value.host !== undefined) {
const h = value.host;
if (!SCOPE_PLAIN(h) || Object.keys(h).some((k) => !["agents", "hosts", "commands", "commandPrefixes"].includes(k))) return null;
if (!SCOPE_IDS(h.agents) || !Array.isArray(h.hosts) || !h.hosts.every((x) => typeof x === "string" && HOST_ID.test(x)) || !SCOPE_IDS(h.hosts)) return null;
if (h.commands === undefined && h.commandPrefixes === undefined) return null;
if (h.commands !== undefined && !SCOPE_ARGVS(h.commands)) return null;
if (h.commandPrefixes !== undefined && !SCOPE_ARGVS(h.commandPrefixes)) return null;
out.host = {
agents: [...h.agents], hosts: [...h.hosts],
...(h.commands !== undefined ? { commands: h.commands.map((argv) => [...argv]) } : {}),
...(h.commandPrefixes !== undefined ? { commandPrefixes: h.commandPrefixes.map((argv) => [...argv]) } : {}),
};
}
return out;
}
// What the readers use: a missing key is the empty scope, and a bad value falls back to it, with
// fallback true. The empty scope puts every grant in front of the human, so a bad value fails closed.
export function grantScopeOf(value) {
if (value === undefined) return { scope: GRANT_SCOPE_EMPTY, fallback: false };
const scope = checkGrantScope(value);
return scope === null ? { scope: GRANT_SCOPE_EMPTY, fallback: true } : { scope, fallback: false };
}
// Whether a raise's grant body is inside a checked scope. Only gitea and host bodies can be.
export function grantInScope(scope, body) {
if (body?.method === "gitea") {
const g = scope.gitea;
return Boolean(g) && g.agents.includes(body.agent) &&
Array.isArray(body.target?.projects) && body.target.projects.length > 0 &&
body.target.projects.every((p) => g.projects.includes(p));
}
if (body?.method === "host") {
const h = scope.host;
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
const extends_ = (argv, prefix) => argv.length >= prefix.length && prefix.every((x, i) => x === argv[i]);
return Boolean(h) && h.agents.includes(body.agent) && h.hosts.includes(body.host) &&
Array.isArray(body.commands) && body.commands.length > 0 &&
body.commands.every((argv) =>
(h.commands ?? []).some((c) => same(argv, c)) || (h.commandPrefixes ?? []).some((c) => extends_(argv, c)));
}
return false;
}
@@ -0,0 +1,124 @@
// Row 298 (#1804, Q228): the chief of staff's grant scope, launch.grantScope. The shape check, the
// readers' fallback, the match rules (sage-3594 Q2) and the one writer. The broker's verbs, the
// in-scope close and the executors are bus tests (packages/bus/tests/grant-scope.test.mjs).
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs";
import { launchSettings } from "../src/launch-settings.mjs";
import { loadBusiness } from "../src/business.mjs";
import { checkGrantScope, grantInScope, grantScopeOf } from "../src/vocabulary.mjs";
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs";
const roots = [];
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
const GITEA = { gitea: { agents: ["coder"], projects: ["app", "lib"] } };
const HOST = { host: { agents: ["ops"], hosts: ["web1"], commands: [["systemctl", "status", "nginx"]], commandPrefixes: [["ls"]] } };
const gitea = (over = {}) => ({ method: "gitea", agent: "coder", target: { projects: ["app"] }, ...over });
const host = (commands, over = {}) => ({ method: "host", agent: "ops", host: "web1", commands, ...over });
function fixture(mutate) {
const root = tmp();
roots.push(root);
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, "acme");
mutate?.(doc);
const dir = join(root, "config");
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
const limits = launchSettings({ dir, rolesDir, system: systemFor(root) });
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
const revision = () => settings.read("acme").revision;
return { root, dir, rolesDir, file, settings, limits, onDisk, log, revision };
}
test("row 298: checkGrantScope takes the D2 shape and refuses anything else", () => {
assert.deepEqual(checkGrantScope({}), {});
assert.deepEqual(checkGrantScope(GITEA), GITEA);
assert.deepEqual(checkGrantScope(HOST), HOST);
assert.deepEqual(checkGrantScope({ host: { agents: ["ops"], hosts: ["web1"], commands: [["uptime"]] } }).host.commands, [["uptime"]]);
for (const bad of [
null, [], "x", { other: {} }, { gitea: [] }, { gitea: { agents: ["coder"] } }, { gitea: { agents: [], projects: ["app"] } },
{ gitea: { agents: ["coder", "coder"], projects: ["app"] } }, { gitea: { agents: ["Coder"], projects: ["app"] } },
{ gitea: { agents: ["coder"], projects: ["app"], extra: 1 } }, { host: { agents: ["ops"], hosts: ["web1"] } },
{ host: { agents: ["ops"], hosts: ["web_1"], commands: [["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [[]] } },
{ host: { agents: ["ops"], hosts: ["web1"], commands: [["ls"], ["ls"]] } }, { host: { agents: ["ops"], hosts: ["web1"], commands: [["bin/ls"]] } },
{ host: { agents: ["ops"], hosts: ["web1"], commandPrefixes: [["ls", ""]] } }, { "host-enroll": {} },
])
assert.equal(checkGrantScope(bad), null, JSON.stringify(bad));
});
test("row 298: a missing scope is empty and a bad one falls back to empty, which covers nothing", () => {
assert.deepEqual(grantScopeOf(undefined), { scope: {}, fallback: false });
assert.deepEqual(grantScopeOf(GITEA), { scope: GITEA, fallback: false });
const bad = { gitea: { agents: ["coder"], projects: ["app"], extra: true } };
assert.deepEqual(grantScopeOf(bad), { scope: {}, fallback: true });
assert.equal(grantInScope(grantScopeOf(bad).scope, gitea()), false);
assert.equal(grantInScope({}, gitea()), false);
assert.equal(grantInScope({}, host([["ls"]])), false);
});
test("row 298: a Gitea grant is in scope only when its agent and every project are listed", () => {
assert.equal(grantInScope(GITEA, gitea()), true);
assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "lib"] } })), true);
assert.equal(grantInScope(GITEA, gitea({ target: { projects: ["app", "old"] } })), false);
assert.equal(grantInScope(GITEA, gitea({ agent: "research" })), false);
assert.equal(grantInScope(GITEA, gitea({ target: { projects: [] } })), false);
assert.equal(grantInScope(HOST, gitea()), false);
});
test("row 298 (sage-3594 Q2): host commands match whole argv tokens, and enrollment is never in scope", () => {
assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx"]])), true);
// A commands entry covers only itself: no extra trailing arguments, no shorter prefix.
assert.equal(grantInScope(HOST, host([["systemctl", "status", "nginx", "--now"]])), false);
assert.equal(grantInScope(HOST, host([["systemctl", "status"]])), false);
// A commandPrefixes entry covers more whole tokens after it, never part of a token.
assert.equal(grantInScope(HOST, host([["ls"]])), true);
assert.equal(grantInScope(HOST, host([["ls", "-l", "/var"]])), true);
assert.equal(grantInScope(HOST, host([["lsblk"]])), false);
assert.equal(grantInScope(HOST, host([["ls"], ["lsblk"]])), false);
assert.equal(grantInScope(HOST, host([["ls"]], { host: "web2" })), false);
assert.equal(grantInScope(HOST, host([["ls"]], { agent: "coder" })), false);
assert.equal(grantInScope(HOST, { method: "host-enroll", host: "web1" }), false);
assert.equal(grantInScope({ ...HOST, "host-enroll": true }, { method: "host-enroll", host: "web1" }), false);
});
test("row 298: a bad grantScope in the file still loads, and the read shows the fallback", () => {
const f = fixture(doc => { doc.launch.grantScope = { gitea: "everything" }; });
const business = loadBusiness("acme", { dir: f.dir, rolesDir: f.rolesDir });
assert.deepEqual(business.launch.grantScope, { gitea: "everything" });
assert.deepEqual(f.settings.grantScopeRead("acme"), { business: "acme", revision: f.revision(), scope: {}, fallback: true });
});
test("row 298: the writer checks the scope, logs the change and removes the key for {}", () => {
const f = fixture();
assert.deepEqual(f.settings.grantScopeRead("acme").scope, {});
const r = f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA }, "jason");
assert.deepEqual(r.scope, GITEA);
assert.deepEqual(r.old, {});
assert.ok(typeof r.transaction === "string" && r.transaction.length > 0);
assert.deepEqual(f.onDisk().launch.grantScope, GITEA);
assert.equal(f.log().at(-1).actor, "jason");
const back = f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason");
assert.deepEqual(back.old, GITEA);
assert.equal(Object.hasOwn(f.onDisk().launch, "grantScope"), false);
const bytes = readFileSync(f.file, "utf8");
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: { gitea: {} } }, "jason"), e => e.code === "invalid-request");
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: GITEA, extra: 1 }, "jason"), e => e.code === "invalid-request");
assert.throws(() => f.settings.grantScope("acme", { revision: "0".repeat(64), scope: GITEA }, "jason"), e => e.code === "revision-conflict");
assert.throws(() => f.settings.grantScope("acme", { revision: f.revision(), scope: {} }, "jason"), e => e.code === "organization-no-change");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
test("row 298 (sage-3594 Q1): the general writers can't write grantScope", () => {
const f = fixture();
const bytes = readFileSync(f.file, "utf8");
assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "jason"));
assert.throws(() => f.limits.set("acme", { revision: f.revision(), grantScope: GITEA }, "chief-of-staff.chief"));
assert.throws(() => f.settings.set("acme", { revision: f.revision(), instance: "coder", op: "change", grantScope: GITEA }, "jason"), e => e.code === "invalid-request");
assert.throws(() => f.settings.veto("acme", { revision: f.revision(), op: "keep", name: "coder", grantScope: GITEA }, "jason"), e => e.code === "invalid-request");
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
+30 -4
View File
@@ -2,16 +2,20 @@
// starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the
// admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
// argv, and nothing here prints a token: the answers carry ids and names only.
// argv, and nothing here prints a token: the answers carry ids and names only. Row 298 (#1804, Q228):
// scope reads the chief of staff's grant scope and scope set writes it from JSON on stdin; both are
// the human's, and the set is the human's Approve of the change.
import { CliError } from "./errors.mjs";
export const GRANTS_USAGE =
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea | " +
"scope [--business ID] | scope set --revision REV [--business ID] (the scope JSON on stdin)";
const MAX_TOKEN = 16384;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
const MAX_SCOPE = 65536;
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by", "--revision"];
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
@@ -40,6 +44,21 @@ async function readToken(stdin) {
return text;
}
// Row 298: the whole of stdin, one JSON scope. {} is the empty scope.
async function readScope(stdin) {
let text = "";
for await (const chunk of stdin) {
text += chunk;
if (text.length > MAX_SCOPE) throw new CliError("refused: the scope on stdin is too long", 2);
}
if (!text.trim()) throw new CliError("refused: no scope on stdin; pipe the scope JSON, {} for none", 4);
try {
return JSON.parse(text);
} catch {
throw new CliError("refused: the scope on stdin is not JSON", 4);
}
}
// Each shape: the positional count after the verb, the flags and values it takes and needs.
const SHAPES = {
status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] },
@@ -49,6 +68,8 @@ const SHAPES = {
"admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] },
"admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] },
"admin remove": { n: 1, flags: [], need: [], may: ["--business"] },
scope: { n: 0, flags: [], need: [], may: ["--business"] },
"scope set": { n: 0, flags: [], need: ["--revision"], may: ["--business"] },
};
const lines = (s) => {
@@ -71,6 +92,9 @@ export async function grantsCommand(args, io, callFor) {
if (verb === "admin") {
verb = `admin ${rest[0] ?? ""}`;
rest = rest.slice(1);
} else if (verb === "scope" && rest[0] === "set") {
verb = "scope set";
rest = rest.slice(1);
}
// Own keys only, so `constructor` is a usage error, not a verb.
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
@@ -79,7 +103,7 @@ export async function grantsCommand(args, io, callFor) {
[...parsed.flags].some((f) => !shape.flags.includes(f)) ||
Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) ||
shape.need.some((v) => !Object.hasOwn(parsed.values, v)) ||
(verb !== "run" && rest.at(-1) !== "gitea") ||
(verb !== "run" && verb !== "scope" && verb !== "scope set" && rest.at(-1) !== "gitea") ||
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
)
throw new CliError(GRANTS_USAGE, 4);
@@ -93,6 +117,8 @@ export async function grantsCommand(args, io, callFor) {
else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }];
else if (verb === "admin set")
request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }];
else if (verb === "scope") request = ["grants.scope", {}];
else if (verb === "scope set") request = ["grants.scope.set", { revision: v["--revision"], scope: await readScope(io.stdin) }];
else if (verb === "admin rotate")
request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }];
else request = ["grants.admin.remove", { service: "gitea" }];
+4 -11
View File
@@ -7,10 +7,9 @@ import { CliError } from "./errors.mjs";
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
// C2-06 M11: `replies --file LIMITS` replaces replyLimits with the file's object, at the current revision.
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
"reset PATH... [--business ID] | replies --file LIMITS [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants; LIMITS keys: requireCitation, citationsPerReply";
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
"PATCH keys: revision, limits, models, instances, reset, assistants";
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
@@ -68,9 +67,8 @@ export async function limitsCommand(args, io, callFor) {
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
return;
}
const fileVerb = verb === "set" || verb === "replies";
if (!["view", "set", "reset", "replies"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(!fileVerb && values["--file"]) || (fileVerb && !values["--file"]) ||
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
(verb === "reset" && parsed.positional.length < 2)) {
throw new CliError(LIMITS_USAGE, 4);
}
@@ -80,11 +78,6 @@ export async function limitsCommand(args, io, callFor) {
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
let result;
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
else if (verb === "replies") {
const replyLimits = JSON.parse(readFileSync(values["--file"], "utf8"));
const { revision } = await call("limits", {});
result = await call("library.limits.set", { revision, replyLimits });
}
else if (verb === "reset") {
const { revision } = await call("limits", {});
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
-197
View File
@@ -1,197 +0,0 @@
// C2-06 (design 5.2, rulings after r11 Q222, Q223 and Q226): the citation guard. The broker runs it
// before it stores an agent's message to the human in a business with a library project. It reads
// only the citations, never what the text says. Codes are design 5.2's table.
import { git } from '../../project/src/git.mjs';
import { classify } from './scope.mjs';
import { listTree, readLibrary, readTexts, splitLines } from './reader.mjs';
import { LibraryError } from './problems.mjs';
import { mirrorPath, readSyncState } from './mirror.mjs';
import { plain } from './limits.mjs';
export const GUARD_CODES = Object.freeze(['reply-invalid', 'citation-missing', 'citations-over-limit', 'citation-invalid',
'playground-cited-as-canon', 'citation-cross-universe', 'citation-unfetched']);
// The runner's four fixed texts in a library business. The guard accepts exactly these bodies when
// requireCitation is true; an agent that sends one gains nothing it couldn't say anyway.
export const FIXED_TEXTS = Object.freeze({
empty: '(the session gave no answer)',
failed: '(the turn failed)',
uncited: '(no answer passed the citation check)',
tooLong: '(the answer was too long to send)',
});
// Web cites are for these definitions only (design 5.2).
export const WEB_CITE_DEFINITIONS = Object.freeze(['research', 'companion']);
// The reason reaches the runner (sage-3599): the guard's own text naming the check, the cite and the
// limit, never a quote or file text, and at most REASON_MAX characters.
export const REASON_MAX = 300;
export class GuardError extends LibraryError {
constructor(code, reason) { reason = reason.slice(0, REASON_MAX); super(code, reason); this.reason = reason; }
}
const refuse = (code, reason) => { throw new GuardError(code, reason); };
const SHA = /^[a-f0-9]{40}$/;
const LIBRARY_CITE = ['path', 'commit', 'lines', 'quote'];
const WEB_CITE = ['url', 'quote'];
const only = (value, required, optional = []) => plain(value)
&& required.every(k => Object.hasOwn(value, k)) && Object.keys(value).every(k => required.includes(k) || optional.includes(k));
const text = value => typeof value === 'string' && value.length > 0 && !value.includes('\0');
function libraryCite(c) {
return only(c, LIBRARY_CITE, ['as']) && text(c.path) && c.path.length <= 4096 && typeof c.commit === 'string' && SHA.test(c.commit)
&& Array.isArray(c.lines) && c.lines.length === 2 && c.lines.every(n => Number.isSafeInteger(n) && n >= 1) && c.lines[0] <= c.lines[1]
&& text(c.quote) && (!Object.hasOwn(c, 'as') || c.as === 'canon');
}
const webCite = c => only(c, WEB_CITE) && text(c.url) && c.url.length <= 4096 && text(c.quote);
// A body is free text, or a JSON object {text, citations} that must fit the schema. Returns
// { text, citations, cited } where `cited` says the body was the JSON form.
export function parseReply(body) {
let value;
try { value = JSON.parse(body); } catch { return { text: body, citations: [], cited: false }; }
if (!plain(value)) return { text: body, citations: [], cited: false };
if (!only(value, ['text', 'citations']) || typeof value.text !== 'string' || !Array.isArray(value.citations))
refuse('reply-invalid', 'a JSON object reply holds exactly "text", a string, and "citations", a list');
value.citations.forEach((c, i) => {
if (!libraryCite(c) && !webCite(c))
refuse('reply-invalid', `citation ${i + 1} is neither {path, commit, lines, quote, as?} nor {url, quote}`);
});
return { text: value.text, citations: value.citations, cited: true };
}
const squash = value => value.replace(/\s+/g, ' ').trim();
// Design 5.2's checks, in the table's order. `limits` comes from resolveReplyLimits. `library` reads
// git for a library cite (libraryReader below). `web` is { allowed, pages } for this sender: whether
// its definition may cite a url, and the session's kept fetches. Returns the stored check result,
// { citations: [{ scope, agent, file }] } in body order (agreed with row 225, morgana-0198 and
// morgana-0200; a url cite is { scope: 'web', agent: null }), or null for a
// plain body. Throws GuardError.
export async function checkReply(body, { limits, mapping, library, web }) {
if (Object.values(FIXED_TEXTS).includes(body)) return null;
const reply = parseReply(body);
const { citations } = reply;
if (limits.requireCitation && citations.length === 0)
refuse('citation-missing', 'replyLimits.requireCitation is true, so a reply carries at least one citation');
if (citations.length > limits.citationsPerReply)
refuse('citations-over-limit', `replyLimits.citationsPerReply is ${limits.citationsPerReply}; the reply carries ${citations.length}`);
if (!reply.cited) return null;
const found = [];
for (const [i, c] of citations.entries()) {
if (!Object.hasOwn(c, 'path')) { found.push(null); continue; }
const at = `citation ${i + 1} (${c.path})`;
const where = classify(mapping, c.path);
if (where.scope === 'unmapped') refuse('citation-invalid', `${at} names a path that isn't mapped`);
const lines = await library.lines(c.commit, c.path);
if (lines === null) refuse('citation-invalid', `${at} isn't an indexed file at a commit reachable from the served commit`);
if (c.lines[1] > lines.length) refuse('citation-invalid', `${at} cites lines past the file's ${lines.length}`);
const quote = squash(c.quote);
if (!quote || !squash(lines.slice(c.lines[0] - 1, c.lines[1]).join('\n')).includes(quote))
refuse('citation-invalid', `${at} quote isn't in lines ${c.lines[0]}-${c.lines[1]}`);
found.push(where);
}
citations.forEach((c, i) => {
if (c.as === 'canon' && found[i].scope !== 'canon')
refuse('playground-cited-as-canon', `citation ${i + 1} (${c.path}) is ${found[i].scope}, not canon`);
});
const universes = [...new Set(found.filter(Boolean).map(w => w.universe))];
if (universes.length > 1) {
const hybrid = universes.find(u => mapping.universes[u]?.kind === 'hybrid');
const sources = hybrid ? mapping.universes[hybrid].sources : [];
if (!hybrid || !universes.every(u => u === hybrid || sources.includes(u)))
refuse('citation-cross-universe', `the citations span universes ${universes.join(', ')}`);
}
citations.forEach((c, i) => {
if (!Object.hasOwn(c, 'url')) return;
if (!web.allowed) refuse('citation-unfetched', `citation ${i + 1}: only research and companion cite a url`);
const quote = squash(c.quote);
const page = web.pages.find(p => p.url === c.url || p.finalUrl === c.url);
if (!page) refuse('citation-unfetched', `citation ${i + 1}: this session fetched no such url through the bus`);
if (!quote || !squash(page.text).includes(quote)) refuse('citation-unfetched', `citation ${i + 1}: the quote isn't in the url's kept text`);
});
const out = [];
for (const [i, c] of citations.entries())
out.push(found[i] ? { scope: found[i].scope, ...await library.origin(c.commit, c.path) } : { scope: 'web', agent: null });
return { citations: out };
}
// Git reads for one check against the bare mirror. `lines` is the file's lines at `commit` when the
// commit is reachable from the served commit and readLibrary indexes the path there (sage-3595 item 4:
// readLibrary at the cited commit stands in for the C2-05a index), else null. Reads are cached per
// commit for the one check.
export function libraryReader(mirror, served, mapping) {
const indexed = new Map(), reach = new Map();
const once = (cache, key, read) => {
if (!cache.has(key)) cache.set(key, read().catch(() => null));
return cache.get(key);
};
const reachable = commit => once(reach, commit, async () => {
if (!mirror || !served) return false;
if (await git(mirror, ['cat-file', '-t', commit]) !== 'commit') return false;
await git(mirror, ['merge-base', '--is-ancestor', commit, served]);
return true;
});
const index = commit => once(indexed, commit, async () => {
const [read, entries] = await Promise.all([readLibrary({ repo: mirror, commit, mapping }), listTree(mirror, commit)]);
return { files: new Set(read.files.map(file => file.path)), entries };
});
return {
async lines(commit, path) {
if (!await reachable(commit)) return null;
const at = await index(commit);
if (!at?.files.has(path)) return null;
const texts = await readTexts(mirror, [at.entries.find(entry => entry.path === path)]).catch(() => new Map());
return texts.has(path) ? splitLines(texts.get(path)) : null;
},
// One git call: the file's last commit at `commit` (morgana-0200: the Console compares it with the
// served tip's, as the cited library commit needn't touch the file) and that commit's trailer.
async origin(commit, path) {
if (!mirror) return { agent: null, file: null };
try {
const [file, trailer = ''] = (await git(mirror, ['log', '-1', '--format=%H%x00%(trailers:key=Mosaic-Agent,valueonly)', commit, '--', path])).split('\0');
return { agent: trailer.trim() || null, file: SHA.test(file) ? file : null };
} catch { return { agent: null, file: null }; }
},
// The file's last commit at the served tip, for thread.messages (sage-3595), or null.
async last(path) {
if (!mirror || !served) return null;
try {
const value = await git(mirror, ['log', '-1', '--format=%H', served, '--', path]);
return SHA.test(value) ? value : null;
} catch { return null; }
},
};
}
// thread.messages' `last` (sage-3595): each path's last commit at the served tip, one git call per
// distinct path. A path maps to null when there is no served commit or the read fails.
export async function lastCommits(context, paths) {
const { mirror, served } = await servedMirror(context);
const reader = libraryReader(mirror, served, null);
const out = new Map();
for (const path of new Set(paths)) out.set(path, await reader.last(path));
return out;
}
// The library project's bare mirror and its served commit. Either is null when there is none yet, so
// a plain reply still passes and any library cite refuses citation-invalid.
async function servedMirror(context) {
const mapping = context.business.projects[context.project];
let mirror = null, served = null;
try {
mirror = mirrorPath(context);
served = (await readSyncState(mirror, { branch: mapping.branch })).served ?? null;
} catch { served = null; }
return { mapping, mirror, served };
}
// The broker's entry: the library project's mirror at its served commit, then checkReply. `context`
// is { business, project, dataRoot }. `definition` is the sender's role definition, which decides only
// whether a url cite may pass; `pages` is the broker's page store for the sender's session.
export async function guardReply(context, body, { limits, definition, pages }) {
const { mapping, mirror, served } = await servedMirror(context);
const web = { allowed: WEB_CITE_DEFINITIONS.includes(definition), pages };
return checkReply(body, { limits, mapping, library: libraryReader(mirror, served, mapping), web });
}
-2
View File
@@ -8,5 +8,3 @@ export { renderItems, KINDS, NOTICES, UNREADABLE } from './render.mjs';
export { survey, formatSurvey, surveyMain } from './survey.mjs';
export { SYNC_STATE_FILE, mirrorPath, ensureMirror, withLibraryLock, servedCommit, readSyncState, libraryStatus, readLibraryFile } from './mirror.mjs';
export { syncCommand, sync, ASKPASS, SLOT, SYNC_INTERVAL_MS } from './sync.mjs';
export { REPLY_LIMITS, resolveReplyLimits, checkReplyLimits } from './limits.mjs';
export { GUARD_CODES, FIXED_TEXTS, WEB_CITE_DEFINITIONS, REASON_MAX, GuardError, parseReply, checkReply, libraryReader, guardReply, lastCommits } from './guard.mjs';
-41
View File
@@ -1,41 +0,0 @@
// C2-06 (design 5.2): the business file's replyLimits, read by the citation guard and written only
// by the human's library.limits.set. Pure, so the business package's organization writer imports it.
export const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value);
// The replyLimits keys, their defaults and their valid values. A key is read only when Object.hasOwn
// finds it and its value passes `valid`; anything else is the default (webby-0144 R3).
export const REPLY_LIMITS = Object.freeze({
requireCitation: { default: false, valid: v => typeof v === 'boolean' },
citationsPerReply: { default: 20, valid: v => Number.isInteger(v) && v >= 1 && v <= 1000 },
});
// `read` is { readable: false } when the business file is missing, unreadable, not JSON or not an
// object at check time, else { readable: true, document }. An unreadable file checks at the defaults
// except requireCitation, which is true so a setting she turned on doesn't fail open (webby-0151 R2).
export function resolveReplyLimits(read) {
const limits = {}, fallback = [];
const unreadable = !read?.readable || !plain(read.document);
const raw = unreadable ? undefined : read.document.replyLimits;
for (const [key, { default: value, valid }] of Object.entries(REPLY_LIMITS)) {
if (plain(raw) && Object.hasOwn(raw, key) && valid(raw[key])) limits[key] = raw[key];
else {
limits[key] = value;
// A key fell back when replyLimits is there but isn't an object, or holds the key with a value
// outside the table. A key left out is simply its default.
if (raw !== undefined && (!plain(raw) || Object.hasOwn(raw, key))) fallback.push(key);
}
}
if (unreadable) limits.requireCitation = true;
return { ...limits, fallback, unreadable };
}
// library.limits.set's check: the whole object is replaced, a key left out means its default, and an
// unknown key or a value outside the table refuses. Returns the object to write.
export function checkReplyLimits(value) {
if (!plain(value)) return null;
for (const [key, v] of Object.entries(value))
if (!Object.hasOwn(REPLY_LIMITS, key) || !REPLY_LIMITS[key].valid(v)) return null;
return structuredClone(value);
}