@passioncode-ai/fabric-agent-adapter 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +10 -7
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +32 -15
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +46 -14
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -2
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +5 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +35 -20
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +77 -9
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +167 -36
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -19
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +107 -16
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +270 -49
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +8 -2
  20. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -17,6 +17,8 @@ import { secretValue } from './fabric-secrets.mjs';
17
17
  export const ESTATE_RECORD_SCHEMA = 'estate-record/1';
18
18
  export const ESTATE_CHALLENGE_TYPE = 'estate-revision/1';
19
19
  export const MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60;
20
+ /** How far ahead of the verifier's clock a signed `issuedAt` may be before it is `not-yet-valid` (DEC-0066). */
21
+ export const CLOCK_SKEW_SECONDS = 60;
20
22
  export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
21
23
  export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT: 0x40, ED: 0x80 });
22
24
 
@@ -153,6 +155,34 @@ export function estateTime(value) {
153
155
  if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
154
156
  return Date.parse(value);
155
157
  }
158
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
159
+ /** `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063). */
160
+ const SIGNING_RP = new RegExp(`^sign(?:\\.${LABEL}){2,}$`);
161
+
162
+ /**
163
+ * Whether an RP ID is a dedicated signing RP (v4 §7, DEC-0063): first label `sign`, then the organization's or
164
+ * PassionCode's domain, lower case. A credential under any other RP ID is a sign-in credential and never signs.
165
+ */
166
+ export const isSigningRpId = (rpId) => isString(rpId) && rpId.length <= 253 && SIGNING_RP.test(rpId);
167
+
168
+ /** Whether a pinned origin may run a signing ceremony for `rpId`: https, no port, no path, the RP ID or a host under it. */
169
+ export function originWithin(origin, rpId) {
170
+ const host = isString(origin) ? /^https:\/\/([a-z0-9.-]{1,253})$/.exec(origin)?.[1] : undefined;
171
+ return host !== undefined && (host === rpId || host.endsWith(`.${rpId}`));
172
+ }
173
+
174
+ /** FAC-SEM-092 on one signing credential: a signing RP ID, and every pinned origin within it. */
175
+ function signingCredential(credential, path, flag) {
176
+ const value = isObject(credential) ? credential : {};
177
+ if (!isSigningRpId(value.rpId)) {
178
+ flag(`${path}/rpId`, 'a passkey signs only under a dedicated signing RP, sign.<domain>, never under the RP ID it signs in with');
179
+ return;
180
+ }
181
+ (Array.isArray(value.origins) ? value.origins : []).forEach((origin, index) => {
182
+ if (!originWithin(origin, value.rpId)) flag(`${path}/origins/${index}`, `a signing ceremony runs only on ${value.rpId} or a host under it, over https without a port`);
183
+ });
184
+ }
185
+
156
186
  const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
157
187
  const clockOf = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
158
188
 
@@ -206,13 +236,18 @@ export function verifyEstateRecord(record, trust) {
206
236
  credentialId: String(authority.credentialId), alg: String(authority.alg), publicKey: isObject(authority.publicKey) ? authority.publicKey : {},
207
237
  rpId: String(authority.rpId), origins: Array.isArray(authority.origins) ? authority.origins.filter(isString) : [],
208
238
  };
239
+ // DEC-0063: the estate passkey is a signing credential. An RP ID that is not sign.<domain> is wrong-rp before the
240
+ // assertion is read; an origin outside the RP ID is not pinned, so a ceremony that ran there is wrong-origin.
241
+ if (!isSigningRpId(authority.rpId)) return { ok: false, problem: 'wrong-rp' };
242
+ credential.origins = credential.origins.filter((origin) => originWithin(origin, credential.rpId));
209
243
  const assertion = verifyAssertion(signature, credential, estateChallenge(digest));
210
244
  if (!assertion.ok) return { ok: false, problem: assertion.problem };
211
245
 
212
246
  const issuedAt = estateTime(record.issuedAt);
213
247
  const validUntil = estateTime(record.validUntil);
214
248
  if (!Number.isFinite(issuedAt) || !Number.isFinite(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_VALIDITY_SECONDS * 1000) return { ok: false, problem: 'too-long-lived' };
215
- if (at < issuedAt) return { ok: false, problem: 'not-yet-valid' };
249
+ // A revision issued a moment ahead of this clock is admitted within the skew every relay time allows (DEC-0066).
250
+ if (at < issuedAt - CLOCK_SKEW_SECONDS * 1000) return { ok: false, problem: 'not-yet-valid' };
216
251
  if (at > validUntil) return { ok: false, problem: 'expired' };
217
252
  const codes = [...new Set(recordFindings(record).map((finding) => finding.code))];
218
253
  if (codes.length > 0) return { ok: false, problem: 'rule-violation', codes };
@@ -227,19 +262,50 @@ export function placementTrust(record, machineId, agentRef) {
227
262
  return { publisher: list(record.publisherKeys).filter((key) => key.kid === placement.publisherKid) };
228
263
  }
229
264
 
265
+ const MAX_WINDOW_MS = MAX_VALIDITY_SECONDS * 1000;
266
+ const isRevision = (value) => Number.isSafeInteger(value) && value >= 1;
267
+ const refused = (fault, detail) => ({ certified: false, fault, detail });
268
+
230
269
  /**
231
- * DL-14 with DEC-0055: trust a token's `kid` only while a current revision certifies it for a machine and does not revoke
232
- * it. `record` is a revision the caller verified; `x` the key the published key set gives for `kid`; `at` the clock.
270
+ * DL-14 (DEC-0055, DEC-0060): whether `record` certifies the delegation key `kid` with the published key's `x` at `at`
271
+ * (a Date or epoch seconds), for a verifier whose machine applied `applied` (`{revision, digest?}`). `extraRevocations`
272
+ * are the revocation delta's entries applied since the revision (DEC-0054). The contract's `hubKeyCertified`, in its
273
+ * order; never throws. Answers `{certified: true, machine, revision}` or `{certified: false, fault, detail}`: fault
274
+ * `token` (`kid-not-certified`), `estate` (`estate-unreadable`, `estate-outside-window`, `estate-rolled-back`,
275
+ * `estate-forked`) or `clock` (`no-clock`).
233
276
  */
234
- export function hubKeyCertified(record, kid, x, at) {
277
+ export function hubKeyCertified(record, kid, x, at, applied, options) {
235
278
  const now = clockOf(at);
236
- if (!isObject(record) || !Number.isFinite(now)) return false;
279
+ if (Number.isNaN(now)) return refused('clock', 'no-clock');
280
+ if (!isObject(record) || !isRevision(record.revision) || !isString(record.digest) || !Array.isArray(record.machines)) return refused('estate', 'estate-unreadable');
281
+ if (record.revocations !== undefined && !Array.isArray(record.revocations)) return refused('estate', 'estate-unreadable');
282
+ const extra = options?.extraRevocations;
283
+ if (extra !== undefined && !Array.isArray(extra)) return refused('estate', 'estate-unreadable');
237
284
  const issuedAt = estateTime(record.issuedAt);
238
285
  const validUntil = estateTime(record.validUntil);
239
- if (!(Number.isFinite(issuedAt) && Number.isFinite(validUntil) && issuedAt <= now && now <= validUntil)) return false;
240
- const revoked = new Set(list(record.revocations).map(revocationKey));
241
- return list(record.machines).some((machine) => isObject(machine.hubKey) && machine.hubKey.kid === kid && machine.hubKey.x === x
242
- && !revoked.has(`hub-key ${kid}`) && !revoked.has(`machine ${String(machine.id)}`));
286
+ // A window that is not a time, not positive or longer than 30 days is no window: NaN fails every comparison below.
287
+ if (Number.isNaN(issuedAt) || Number.isNaN(validUntil) || validUntil <= issuedAt || validUntil - issuedAt > MAX_WINDOW_MS) return refused('estate', 'estate-unreadable');
288
+ if (applied !== undefined) {
289
+ if (!isObject(applied) || !isRevision(applied.revision) || (applied.digest !== undefined && !isString(applied.digest))) return refused('estate', 'estate-unreadable');
290
+ // An older revision may still be inside its window and list a key the applied one revokes (a rollback).
291
+ if (record.revision < applied.revision) return refused('estate', 'estate-rolled-back');
292
+ if (record.revision === applied.revision && applied.digest !== undefined && record.digest !== applied.digest) return refused('estate', 'estate-forked');
293
+ }
294
+ // The window verifyEstateRecord admits the revision in: from 60 s before issuedAt to validUntil (DEC-0066).
295
+ if (now < issuedAt - CLOCK_SKEW_SECONDS * 1000 || now > validUntil) return refused('estate', 'estate-outside-window');
296
+
297
+ const revocations = [...(record.revocations ?? []), ...(extra ?? [])];
298
+ // A ref is compared exactly, never as a string conversion.
299
+ const revokes = (kind, ref) => revocations.some((entry) => isObject(entry) && entry.kind === kind && entry.ref === ref);
300
+ if (!isString(kid) || revokes('hub-key', kid)) return refused('token', 'kid-not-certified');
301
+ const holders = record.machines.filter((machine) => isObject(machine) && isObject(machine.hubKey) && machine.hubKey.kid === kid);
302
+ // Two machines under one kid break FAC-SEM-073; such a revision certifies neither.
303
+ if (holders.length !== 1) return refused('token', 'kid-not-certified');
304
+ const [machine] = holders;
305
+ // A missing x never equals a missing x: the published key must be the certified one.
306
+ if (!isString(x) || machine.hubKey.x !== x) return refused('token', 'kid-not-certified');
307
+ if (revokes('machine', machine.id)) return refused('token', 'kid-not-certified');
308
+ return { certified: true, machine: String(machine.id), revision: record.revision };
243
309
  }
244
310
 
245
311
  // ---- FAC-SEM-072…076 ----
@@ -396,6 +462,8 @@ function recordFindings(value) {
396
462
  placements(value, flagger(findings, 'FAC-SEM-074'));
397
463
  secretReferences(value, flagger(findings, 'FAC-SEM-075'));
398
464
  grantsAndRevocations(value, flagger(findings, 'FAC-SEM-076'));
465
+ // FAC-SEM-092 (DEC-0063) on each passkey the record lists.
466
+ list(value.authorities).forEach((authority, index) => signingCredential(authority, `/authorities/${index}`, flagger(findings, 'FAC-SEM-092')));
399
467
  return findings;
400
468
  }
401
469
 
@@ -52,7 +52,8 @@ const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
52
52
  const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
53
53
  const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
54
54
  // DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
55
- const LOCAL_PATH = /^(~[\\/]|\/(?!\/)|[A-Za-z]:[\\/])/;
55
+ // DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
56
+ const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
56
57
  export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'none']);
57
58
  const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
58
59
  const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
@@ -63,7 +64,8 @@ export const DEFAULT_EXIT_TIMEOUT = 15;
63
64
  export class ServiceError extends Error {}
64
65
  export class AlreadyRunning extends ServiceError {
65
66
  constructor(holderPid, lockPath) {
66
- super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'another process'} holds ${lockPath}).`);
67
+ // FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
68
+ super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'an unknown process'} holds ${lockPath}).`);
67
69
  this.holderPid = holderPid;
68
70
  this.lockPath = lockPath;
69
71
  }
@@ -209,25 +211,92 @@ function readPid(file) {
209
211
  }
210
212
  }
211
213
 
214
+ /** The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node. */
215
+ export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node']);
216
+ /** The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action). */
217
+ export const SUPERVISOR_ARG = '--fabric-supervisor';
218
+
219
+ /**
220
+ * The marker values an argument vector carries after the executable and before `--`. A bare `--fabric-supervisor`
221
+ * followed by nothing, by `--` or by another `--…` flag is a marker with an empty value and consumes nothing.
222
+ */
223
+ function markerArguments(argv) {
224
+ const values = [];
225
+ if (!Array.isArray(argv)) return values;
226
+ for (let index = 1; index < argv.length; index += 1) {
227
+ const item = argv[index];
228
+ if (item === '--') break;
229
+ if (item === SUPERVISOR_ARG) {
230
+ const next = argv[index + 1];
231
+ if (typeof next === 'string' && !next.startsWith('--')) {
232
+ values.push(next);
233
+ index += 1;
234
+ } else {
235
+ values.push('');
236
+ }
237
+ } else if (typeof item === 'string' && item.startsWith(`${SUPERVISOR_ARG}=`)) {
238
+ values.push(item.slice(SUPERVISOR_ARG.length + 1));
239
+ }
240
+ }
241
+ return values;
242
+ }
243
+
244
+ /**
245
+ * How this copy was started (DEC-0057), from `FABRIC_SERVICE_SUPERVISOR=<manager>` and `--fabric-supervisor <manager>` /
246
+ * `--fabric-supervisor=<manager>` (`env` and `argv` default to this process's; `argv[0]` is the executable or script):
247
+ * `{kind: 'supervised', manager, forms}` when every marker present names the same manager of SUPERVISORS;
248
+ * `{kind: 'conflict', values, forms}` when they disagree (an empty or bare argument beside a variable disagrees with it):
249
+ * the copy exits 75 at once, before it takes the lock; `{kind: 'manual', forms, unreadable?}` with no marker (only an
250
+ * empty variable counts as none) or one that names no manager. Never inferred from the parent process. The contract's
251
+ * `supervisorMarker`.
252
+ */
253
+ export function supervisorMarker({ env = process.env, argv = process.argv.slice(1) } = {}) {
254
+ const variable = env?.[SUPERVISOR_ENV];
255
+ const fromEnv = typeof variable === 'string' && variable !== '' ? [variable] : [];
256
+ const fromArgs = markerArguments(argv);
257
+ const forms = [...(fromEnv.length ? ['environment'] : []), ...(fromArgs.length ? ['argument'] : [])];
258
+ const values = [...new Set([...fromEnv, ...fromArgs])];
259
+ if (values.length > 1) return { kind: 'conflict', values, forms };
260
+ if (values.length === 0) return { kind: 'manual', forms };
261
+ return SUPERVISORS.includes(values[0]) ? { kind: 'supervised', manager: values[0], forms } : { kind: 'manual', unreadable: values[0], forms };
262
+ }
263
+
264
+ /** How long a copy started so may wait for the lock, in seconds: 300 when supervised, else not at all. */
265
+ export const lockWaitBudgetSeconds = (marker) => (marker?.kind === 'supervised' ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
266
+
267
+ /** The one sentence a copy whose markers disagree exits with, naming the markers present. */
268
+ export function markerConflictMessage(marker) {
269
+ const shown = marker.values.map((v) => JSON.stringify(v)).join(', ');
270
+ return marker.forms.length === 2
271
+ ? `${SUPERVISOR_ENV} and ${SUPERVISOR_ARG} disagree (${shown}); not starting unsupervised.`
272
+ : `${SUPERVISOR_ARG} is given with different values (${shown}); not starting unsupervised.`;
273
+ }
274
+
275
+ /** True when a supervisor that restarts on exit started this process and said so with the marker. */
276
+ export const supervised = (start) => supervisorMarker(start).kind === 'supervised';
212
277
  /** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
213
- export const supervisedByLaunchd = () => process.env[SUPERVISOR_ENV] === 'launchd';
214
- export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler']);
215
- /** True when a supervisor that restarts on exit started this process and said so (launchd, systemd). */
216
- export const supervised = () => SUPERVISORS.includes(process.env[SUPERVISOR_ENV]);
278
+ export const supervisedByLaunchd = (start) => supervisorMarker(start).manager === 'launchd';
217
279
 
218
280
  const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
219
281
 
220
282
  /**
221
- * Take the lock or exit 75 with one sentence naming the holder. Started by hand, a held lock exits
222
- * 75 at once. Under launchd (KeepAlive) an immediate exit is a respawn every ThrottleInterval,
283
+ * Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078). Markers that
284
+ * disagree exit 75 before the lock. Started by hand, a held lock exits 75 at once. Under a
285
+ * supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
223
286
  * forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
224
287
  * LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
225
288
  * the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
226
289
  */
227
290
  export async function holdSingleInstance(dataDir, {
228
- waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code),
291
+ waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
229
292
  } = {}) {
230
- const wait = waitSeconds ?? (supervised() ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
293
+ // DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
294
+ const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
295
+ if (marker.kind === 'conflict') {
296
+ process.stderr.write(`${markerConflictMessage(marker)}\n`);
297
+ return exit(EXIT_ALREADY_RUNNING);
298
+ }
299
+ const wait = waitSeconds ?? lockWaitBudgetSeconds(marker);
231
300
  const deadline = clock() + Math.max(0, wait);
232
301
  let delay = LOCK_BACKOFF_FIRST_SECONDS;
233
302
  let announced = false;
@@ -263,9 +332,8 @@ export function ensureToken(file) {
263
332
 
264
333
  /**
265
334
  * Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
266
- * service.md "Windows token files" (DEC-0032, DEC-0033): a regular file whose real path is in the
267
- * profile, owned by the current user, SYSTEM or Administrators, every granting ACE naming one of
268
- * those three; a refusal names the SID, never the contents.
335
+ * service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057): windowsTokenAclProblem over the
336
+ * file's flags, owner and ACEs; a refusal names the SID, never the contents.
269
337
  */
270
338
  export function readToken(file) {
271
339
  const info = fs.lstatSync(file);
@@ -286,30 +354,68 @@ export function readToken(file) {
286
354
  // Node has no ACL API. Windows PowerShell (5.1, present on every Windows) reads the list through .NET
287
355
  // and icacls writes it; both get the path through the environment, never argv, and print no contents.
288
356
 
357
+ export const SYSTEM_SID = 'S-1-5-18';
358
+ export const ADMINISTRATORS_SID = 'S-1-5-32-544';
359
+ /** A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each. */
360
+ const SID_TEXT = /^S-1-\d{1,15}(-\d{1,10}){0,15}$/i;
361
+ /** A readable SID in upper case (SIDs compare case-insensitively on their S), or undefined. */
362
+ const sidOf = (value) => (typeof value === 'string' && SID_TEXT.test(value) && value.split('-').slice(3).every((part) => Number(part) <= 0xffffffff) ? value.toUpperCase() : undefined);
363
+
289
364
  /**
290
- * service.md "Windows token files", rules 2 and 3, on SIDs already read: the owner is the current
291
- * user, SYSTEM or Administrators (DEC-0033), and every SID an allow ACE grants anything to is one of
292
- * those three. Deny ACEs are not passed in: they do not change the decision.
365
+ * service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read: `file` is
366
+ * `{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}`, the mask the ACE's unsigned
367
+ * 32-bit access mask. The first refusal `{problem, at, sid?, message}`, or null when the file may be read: a regular
368
+ * file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every allow ACE with a
369
+ * non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything unreadable refused.
370
+ * The contract's `windowsTokenAclProblem`; a refusal names the SID, never the contents.
293
371
  */
294
- export function tokenAclProblem(file, owner, granting, user) {
295
- if (owner !== user && !WINDOWS_TRUSTED.includes(owner)) {
296
- return `Token file ${file} is owned by ${owner || 'nobody'}, not by this user (${user}), SYSTEM or Administrators.`;
297
- }
298
- for (const sid of granting) {
299
- if (sid !== user && !WINDOWS_TRUSTED.includes(sid)) return `Token file ${file} grants access to ${sid}; only this user, SYSTEM and Administrators may hold any.`;
372
+ export function windowsTokenAclProblem(file, currentUserSid) {
373
+ const refuse = (problem, at, message, sid) => (sid === undefined ? { problem, at, message } : { problem, at, sid, message });
374
+ const user = sidOf(currentUserSid);
375
+ if (user === undefined) return refuse('unreadable', '/currentUserSid', "the current user's SID is unreadable");
376
+ if (typeof file !== 'object' || file === null || Array.isArray(file)) return refuse('unreadable', '/file', "the token file's metadata is unreadable");
377
+ for (const flag of ['isRegularFile', 'isReparsePoint', 'realPathInProfile']) {
378
+ if (typeof file[flag] !== 'boolean') return refuse('unreadable', `/file/${flag}`, `${flag} is unreadable`);
379
+ }
380
+ if (file.isReparsePoint) return refuse('reparse-point', '/file/isReparsePoint', 'the token file is a reparse point (a symlink or a junction)');
381
+ if (!file.isRegularFile) return refuse('not-regular-file', '/file/isRegularFile', 'the token file is not a regular file');
382
+ if (!file.realPathInProfile) return refuse('outside-profile', '/file/realPathInProfile', "the token file's real path lies outside the user's profile");
383
+ const trusted = new Set([user, SYSTEM_SID, ADMINISTRATORS_SID]);
384
+ const owner = sidOf(file.ownerSid);
385
+ if (owner === undefined) return refuse('unreadable', '/file/ownerSid', 'the owner SID is unreadable');
386
+ if (!trusted.has(owner)) return refuse('owner', '/file/ownerSid', `the token file is owned by ${owner}, not by the current user, SYSTEM or Administrators`, owner);
387
+ if (!Array.isArray(file.aces)) return refuse('unreadable', '/file/aces', 'the ACL is unreadable');
388
+ for (const [index, ace] of file.aces.entries()) {
389
+ const at = `/file/aces/${index}`;
390
+ if (typeof ace !== 'object' || ace === null || Array.isArray(ace)) return refuse('unreadable', at, `ACE ${index} is unreadable`);
391
+ if (typeof ace.type !== 'string') return refuse('unreadable', `${at}/type`, `ACE ${index} has an unreadable type`);
392
+ const trustee = sidOf(ace.sid);
393
+ if (trustee === undefined) return refuse('unreadable', `${at}/sid`, `ACE ${index} names an unreadable SID`);
394
+ if (typeof ace.mask !== 'number' || !Number.isInteger(ace.mask) || ace.mask < 0 || ace.mask > 0xffffffff) return refuse('unreadable', `${at}/mask`, `ACE ${index} has an unreadable access mask`);
395
+ if (ace.type === 'deny') continue;
396
+ if (ace.type !== 'allow') return refuse('ace-type', `${at}/type`, `ACE ${index} for ${trustee} is of a type the rule does not read`, trustee);
397
+ if (ace.mask === 0) continue;
398
+ if (!trusted.has(trustee)) return refuse('ace-sid', `${at}/sid`, `the token file grants access to ${trustee}; only the current user, SYSTEM and Administrators may hold any`, trustee);
300
399
  }
301
400
  return null;
302
401
  }
303
402
 
403
+ /** windowsTokenAclProblem as one sentence naming the file and the refusal, or null. */
404
+ export function tokenFileProblem(file, metadata, user) {
405
+ const problem = windowsTokenAclProblem(metadata, user);
406
+ return problem ? `Token file ${file} refused: ${problem.message}.` : null;
407
+ }
408
+
409
+ // The raw descriptor, through .NET: every ACE's type, its trustee's SID and its access mask as read (a signed int;
410
+ // parseAclReport converts it to the unsigned mask). An inherit-only ACE applies to children only and is left out.
304
411
  const ACL_SCRIPT = [
305
412
  "$ErrorActionPreference = 'Stop'",
306
- '$sid = [System.Security.Principal.SecurityIdentifier]',
307
413
  '$acl = [System.IO.File]::GetAccessControl($env:FABRIC_ACL_PATH)',
308
- "$null_dacl = $acl.GetSecurityDescriptorSddlForm('Access') -match 'NO_ACCESS_CONTROL' -or $acl.GetSecurityDescriptorSddlForm('Access') -eq ''",
309
- // InheritOnly (2) applies to children, not to the file itself.
310
- "$granting = @($acl.GetAccessRules($true, $true, $sid) | Where-Object { $_.AccessControlType -eq 'Allow' -and -not ([int]$_.PropagationFlags -band 2) } | ForEach-Object { $_.IdentityReference.Value })",
414
+ "$raw = New-Object System.Security.AccessControl.RawSecurityDescriptor($acl.GetSecurityDescriptorSddlForm('Owner, Access'))",
415
+ '$dacl = $raw.DiscretionaryAcl',
416
+ "$aces = @(if ($dacl) { $dacl | Where-Object { -not ([int]$_.AceFlags -band 8) } | ForEach-Object { @{ type = [string]$_.AceType; sid = $(if ($_.SecurityIdentifier) { $_.SecurityIdentifier.Value } else { '' }); mask = [int64]$_.AccessMask } } })",
311
417
  '$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
312
- '[Console]::Out.Write((ConvertTo-Json -Compress @{ owner = $acl.GetOwner($sid).Value; granting = $granting; nullDacl = [bool]$null_dacl; user = $user }))',
418
+ '[Console]::Out.Write((ConvertTo-Json -Compress -Depth 4 @{ owner = $raw.Owner.Value; aces = $aces; nullDacl = [bool](-not $dacl); user = $user }))',
313
419
  ].join('; ');
314
420
 
315
421
  function windowsHelper(command, args, extraEnv = {}) {
@@ -323,14 +429,24 @@ function windowsHelper(command, args, extraEnv = {}) {
323
429
  return result.stdout;
324
430
  }
325
431
 
326
- /** The ACL report PowerShell prints, reduced to (owner, granting SIDs, current user). */
432
+ /** .NET's AceType names as the rule's ACE type: allow and deny are read, every other type is refused (DEC-0057). */
433
+ const ACE_TYPES = { AccessAllowed: 'allow', AccessDenied: 'deny', AccessAllowedObject: 'allow-object', AccessDeniedObject: 'deny-object',
434
+ AccessAllowedCallback: 'allow-callback', AccessDeniedCallback: 'deny-callback', AccessAllowedCallbackObject: 'allow-object-callback',
435
+ AccessDeniedCallbackObject: 'deny-object-callback' };
436
+
437
+ /**
438
+ * The ACL report PowerShell prints, as (owner SID, ACEs `{type, sid, mask}`, current user). The mask arrives signed
439
+ * (GENERIC_READ 0x80000000 is negative in .NET) and is read as the unsigned 32-bit mask; a NULL DACL grants everyone
440
+ * everything and is read as an allow ACE for Everyone (S-1-1-0).
441
+ */
327
442
  export function parseAclReport(text) {
328
443
  const report = JSON.parse(text);
329
- const granting = report.nullDacl ? ['S-1-1-0'] : [].concat(report.granting ?? []);
330
- return { owner: report.owner ?? '', granting, user: report.user };
444
+ const aces = report.nullDacl ? [{ type: 'allow', sid: 'S-1-1-0', mask: 0x1f01ff }]
445
+ : [].concat(report.aces ?? []).map((ace) => ({ type: ACE_TYPES[ace.type] ?? String(ace.type), sid: ace.sid, mask: Number.isInteger(ace.mask) ? ace.mask >>> 0 : ace.mask }));
446
+ return { owner: report.owner ?? '', aces, user: report.user };
331
447
  }
332
448
 
333
- /** (owner SID, SIDs of every allow ACE on the file itself, the current user's SID). */
449
+ /** (owner SID, the ACEs on the file itself, the current user's SID). */
334
450
  export function windowsAcl(file) {
335
451
  return parseAclReport(windowsHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ACL_SCRIPT], { FABRIC_ACL_PATH: file }));
336
452
  }
@@ -356,13 +472,14 @@ export function windowsProtect(target, { folder }) {
356
472
  }
357
473
 
358
474
  function windowsTokenProblem(file, info) {
359
- if (!info.isFile()) return `Token file ${file} is not a regular file (a link or junction); refusing it.`;
360
475
  const profile = fs.realpathSync.native(process.env.USERPROFILE || os.homedir()).toLowerCase();
361
476
  const real = fs.realpathSync.native(file).toLowerCase();
362
477
  const inside = path.win32.relative(profile, real);
363
- if (!inside || inside.startsWith('..') || path.win32.isAbsolute(inside)) return `Token file ${file} lies outside the user's profile; refusing it.`;
364
- const { owner, granting, user } = windowsAcl(file);
365
- return tokenAclProblem(file, owner, granting, user);
478
+ const { owner, aces, user } = windowsAcl(file);
479
+ return tokenFileProblem(file, {
480
+ isRegularFile: info.isFile(), isReparsePoint: info.isSymbolicLink(),
481
+ realPathInProfile: Boolean(inside) && !inside.startsWith('..') && !path.win32.isAbsolute(inside), ownerSid: owner, aces,
482
+ }, user);
366
483
  }
367
484
 
368
485
  export function tokenMatches(presented, token, scheme = 'Bearer') {
@@ -431,6 +548,10 @@ export function validateDescriptor(d) {
431
548
  problems.push('a launchd service declares label and plist');
432
549
  }
433
550
  if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
551
+ // DEC-0057: a descriptor names one supervisor; launchd's label and plist never sit beside another.
552
+ for (const field of ['label', 'plist']) {
553
+ if (life[field] !== undefined && ['systemd', 'task-scheduler'].includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
554
+ }
434
555
  // DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
435
556
  for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
436
557
  if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
@@ -1564,6 +1685,11 @@ function plain(value, what) {
1564
1685
  return value;
1565
1686
  }
1566
1687
 
1688
+ /** The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it. */
1689
+ function noMarkerArgument(argv) {
1690
+ if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
1691
+ }
1692
+
1567
1693
  function noSecretInSupervisorFile(argv, environment) {
1568
1694
  // No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
1569
1695
  for (const arg of argv) {
@@ -1592,7 +1718,8 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
1592
1718
  const env = { ...environment };
1593
1719
  for (const name of Object.keys(env)) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new ServiceError(`Environment name ${JSON.stringify(name)} is not a variable name.`);
1594
1720
  noSecretInSupervisorFile(argv, env);
1595
- env[SUPERVISOR_ENV] ??= 'systemd';
1721
+ noMarkerArgument(programArguments.map(String));
1722
+ env[SUPERVISOR_ENV] = 'systemd'; // the unit's writer writes the marker, whatever the caller passed
1596
1723
  const cwd = plain(String(workingDirectory), 'The working directory');
1597
1724
  if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
1598
1725
  return [
@@ -1666,6 +1793,10 @@ export function taskXml(task, programArguments, { workingDirectory, description,
1666
1793
  plain(String(workingDirectory), 'The working directory');
1667
1794
  plain(description ?? task, 'The description');
1668
1795
  noSecretInSupervisorFile(argv, {});
1796
+ noMarkerArgument(argv);
1797
+ // A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
1798
+ const cut = argv.includes('--') ? argv.indexOf('--') : argv.length;
1799
+ argv.splice(cut, 0, SUPERVISOR_ARG, 'task-scheduler');
1669
1800
  const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
1670
1801
  if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
1671
1802
  plain(who, 'The user');
@@ -309,7 +309,7 @@ def _key_for(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]:
309
309
  return {"problem": "unusable-key"}
310
310
  if is_small_order_key(raw):
311
311
  return {"problem": "small-order-key"} # refused before any verification (DEC-0045, CT-29)
312
- return {"key": raw}
312
+ return {"key": raw, "x": jwk["x"]}
313
313
 
314
314
 
315
315
  def _has_keys(jwks: Any) -> bool:
@@ -331,7 +331,7 @@ def _authentic(token: Any, jwks: Dict[str, Any], typ: str) -> Dict[str, Any]:
331
331
  return {"problem": found["problem"], "issuer": found["problem"] != "unknown-key"}
332
332
  if len(compact["signature"]) != 64 or not ed25519_verify(found["key"], compact["signing_input"], compact["signature"]):
333
333
  return {"problem": "bad-signature"}
334
- return {"compact": compact, "kid": kid}
334
+ return {"compact": compact, "kid": kid, "x": found["x"]}
335
335
 
336
336
 
337
337
  def chain_depth(act: Any) -> float:
@@ -432,30 +432,58 @@ def _approval_shape(payload: Dict[str, Any]) -> bool:
432
432
  return payload.get("method") in ("prompt", "operator-channel") # DEC-0045: `device` is removed
433
433
 
434
434
 
435
- def _estate_problem(estate: Any, jwks: Dict[str, Any], kid: str, now: float) -> Optional[Tuple[str, str]]:
436
- """DL-14 with DEC-0055: a verifier that reads the estate record trusts a kid only while a current revision certifies
437
- it for a machine and does not revoke it. `estate` is a revision the caller verified (fabric_estate.
438
- verify_estate_record) or a callable returning the current one; None skips the rule. (refusal kind, detail) or None."""
435
+ #: ECMAScript's time range in seconds (±8.64e15 ms): a NumericDate outside it is no instant a Date can hold.
436
+ MAX_CLOCK_SECONDS = 8.64e12
437
+
438
+
439
+ def _readable_clock(now: Any) -> bool:
440
+ """A clock the time rows can compare: a finite number of seconds within a Date's range (DEC-0060). NaN or text
441
+ would make every time row false and let an expired token through."""
442
+ if not isinstance(now, (int, float)) or isinstance(now, bool):
443
+ return False
444
+ try:
445
+ return math.isfinite(now) and abs(now) <= MAX_CLOCK_SECONDS
446
+ except OverflowError:
447
+ return False
448
+
449
+
450
+ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = None,
451
+ revocations: Any = None) -> Optional[Tuple[str, str]]:
452
+ """DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the
453
+ token's kid and the published key must be certified by that revision. `estate` is a revision the caller verified
454
+ (fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; None skips the rule (a
455
+ callable returning None is an unreadable source). `applied` is the highest revision the machine applied
456
+ (`{"revision", "digest"?}`); `revocations` the revocation delta's entries applied since (a list or a callable). A
457
+ source that raises is unreadable, never a pass. Returns ("token" | "issuer", detail) or None."""
439
458
  if estate is None:
440
459
  return None
441
460
  try:
442
461
  record = estate() if callable(estate) else estate
443
- except Exception: # an unreadable record certifies nothing; it is never a pass
462
+ extra = revocations() if callable(revocations) else revocations
463
+ except Exception:
444
464
  return ("issuer", "estate-unreadable")
445
465
  import fabric_estate # beside this file; imported here, since fabric_estate imports this module
446
- jwk = next((k for k in jwks["keys"] if isinstance(k, dict) and k.get("kid") == kid), {})
447
- if not fabric_estate.hub_key_certified(record, kid, jwk.get("x"), now):
448
- return ("token", "kid-not-certified")
449
- return None
466
+ verdict = fabric_estate.hub_key_certified(record, kid, x, now, applied,
467
+ fabric_estate._MISSING if extra is None else extra)
468
+ if verdict["certified"]:
469
+ return None
470
+ # The clock was refused before row 1, so a verdict here is the token's or the trust source's.
471
+ return ("token" if verdict["fault"] == "token" else "issuer", verdict["detail"])
450
472
 
451
473
 
452
474
  def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
453
- issuer: Optional[str], read_only: bool = False, estate: Any = None) -> Dict[str, Any]:
475
+ issuer: Optional[str], read_only: bool = False, estate: Any = None,
476
+ estate_applied: Any = None, estate_revocations: Any = None) -> Dict[str, Any]:
454
477
  """DL-13, in its fixed order: nothing about a token is believed before its signature is.
455
478
  `now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
456
479
  {ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
457
480
  With `estate` (a verified estate revision, or a callable returning the current one), the token's kid must be one
458
- the revision certifies for a machine (DL-14, DEC-0055)."""
481
+ the revision certifies for a machine (DL-14, DEC-0055, DEC-0060); `estate_applied` is the highest revision the
482
+ machine applied and `estate_revocations` the revocation delta's entries since. A clock that is not a number of
483
+ seconds is refused `unknown-state` `no-clock` before row 1."""
484
+ # Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
485
+ if not _readable_clock(now):
486
+ return _refuse("unknown-state", "no-clock")
459
487
  if not _is_string(issuer):
460
488
  return _refuse("issuer-unavailable", "no-trusted-issuer")
461
489
  if not _has_keys(jwks):
@@ -463,7 +491,7 @@ def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capab
463
491
  checked = _authentic(token, jwks, DELEGATION_TYP)
464
492
  if "problem" in checked:
465
493
  return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
466
- uncertified = _estate_problem(estate, jwks, checked["kid"], now)
494
+ uncertified = _estate_problem(estate, checked["kid"], checked["x"], now, estate_applied, estate_revocations)
467
495
  if uncertified:
468
496
  return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "unauthenticated", uncertified[1])
469
497
  claims = checked["compact"]["payload"]
@@ -565,13 +593,16 @@ UNSAFE_NO_JTI_STORE = _NoReplayProtection()
565
593
 
566
594
 
567
595
  def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: float, action_digest: str,
568
- jti_store: Any, issuer: Optional[str], estate: Any = None) -> Dict[str, Any]:
596
+ jti_store: Any, issuer: Optional[str], estate: Any = None, estate_applied: Any = None,
597
+ estate_revocations: Any = None) -> Dict[str, Any]:
569
598
  """DL-18: a missing approval is approval-required carrying the action digest; any fault of the document is
570
599
  approval-invalid; a second use is approval-reused. The jti is consumed last."""
571
600
  if approval is None or approval == "":
572
601
  return _refuse("approval-required", "no-approval", {"actionDigest": action_digest})
573
602
  if jti_store is None or not callable(getattr(jti_store, "consume", None)):
574
603
  return _refuse("unknown-state", "no-jti-store")
604
+ if not _readable_clock(now):
605
+ return _refuse("unknown-state", "no-clock")
575
606
  if not _is_string(issuer):
576
607
  return _refuse("issuer-unavailable", "no-trusted-issuer")
577
608
  if not _has_keys(jwks):
@@ -579,7 +610,7 @@ def verify_approval(approval: Optional[str], *, audience: str, jwks: Any, now: f
579
610
  checked = _authentic(approval, jwks, APPROVAL_TYP)
580
611
  if "problem" in checked:
581
612
  return _refuse("issuer-unavailable" if checked.get("issuer") else "approval-invalid", checked["problem"])
582
- uncertified = _estate_problem(estate, jwks, checked["kid"], now)
613
+ uncertified = _estate_problem(estate, checked["kid"], checked["x"], now, estate_applied, estate_revocations)
583
614
  if uncertified:
584
615
  return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "approval-invalid", uncertified[1])
585
616
  claims = checked["compact"]["payload"]
@@ -820,7 +851,8 @@ TIERS = ("T0", "T1", "T2")
820
851
 
821
852
 
822
853
  def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
823
- read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = None) -> Callable[..., Dict[str, Any]]:
854
+ read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = None,
855
+ estate_applied: Any = None, estate_revocations: Any = None) -> Callable[..., Dict[str, Any]]:
824
856
  """A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
825
857
  refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
826
858
  refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
@@ -830,7 +862,8 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
830
862
  relayed from off the machine. A T0 or T1 caller is never required to present a token: with none, the call
831
863
  passes with no delegation ({ok: True, tier, principal: None, ...}). A present token is verified at every tier
832
864
  and refused when invalid. Unknown tiers count as T2. `estate` (a callable returning the current verified estate
833
- revision) makes the gate trust only kids that revision certifies (DL-14, DEC-0055)."""
865
+ revision) makes the gate trust only kids that revision certifies (DL-14, DEC-0055, DEC-0060); `estate_applied` and
866
+ `estate_revocations` pass the applied mark and the revocation delta's entries through."""
834
867
  if not _is_string(issuer):
835
868
  raise ValueError("the gate needs the issuer it trusts (auth.delegation.issuer)")
836
869
 
@@ -845,7 +878,8 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
845
878
  return _refuse("unauthenticated", "no-token")
846
879
  keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
847
880
  verdict = verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
848
- effect=effect, issuer=issuer, read_only=read_only, estate=estate)
881
+ effect=effect, issuer=issuer, read_only=read_only, estate=estate,
882
+ estate_applied=estate_applied, estate_revocations=estate_revocations)
849
883
  if verdict["ok"]:
850
884
  verdict["tier"] = tier if tier in TIERS else "T2"
851
885
  return verdict