sign-off-axi 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -137,8 +137,8 @@ sign-off-axi status is the board open started for th
137
137
  sign-off-axi stop stop that board
138
138
  sign-off-axi serve [--port 4410] [--bind <address>] [--evidence-root <dir>] run the board in the foreground (--port 0 picks a free port)
139
139
  sign-off-axi answers [--key <key>] the latest answer per decision, and how many still wait
140
- sign-off-axi wait [--filer <id>] [--key <key>] [--timeout 600] block until the user answers, then print it like answers
141
- (--filer: only that filer's items; without --key each answer once)
140
+ sign-off-axi wait [--filer <id>] [--key <key>] [--timeout 600] [--events (needs --filer)] block until the user answers, then print it like answers; --events prints one JSON event line per answer and round (docs/contract.md section 7)
141
+ (--filer: only that filer's items, each marked taken; without --key each answer once)
142
142
  sign-off-axi poll [--timeout 600] block until the user comments or queues a prompt, then print each new one once
143
143
  sign-off-axi export [--out <file.html>] write a frozen read-only copy of the board as one HTML file
144
144
  ```
@@ -159,8 +159,8 @@ sign-off-axi export [--out <file.html>] write a frozen read-only copy o
159
159
  - Raising a question that is already on the board is a no-op that reports its status, so an agent can re-run it safely. `--key` defaults to a slug of the title.
160
160
  - Each raised or posted item records when it was filed (`filed`), so Triage shows `Waiting since 09:15` and the Waiting on you tile `Oldest 09:15`; `post --replace` keeps an item's first time.
161
161
  - `open` is a no-op when the same board is already open on the same address, and fails with `CONFLICT` when it runs on another one. `--evidence-root` is the folder holding `.done-done/`, for decisions that link run evidence.
162
- - `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet, marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items still needs an answer. The page never shows the filer id.
163
- - `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `comments`, present when the answer carried comments on mock elements, lists each as `id`, `mock`, `hash`, `ref`, `tag`, `words` and `text`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
162
+ - `--filer <id>` on `post` and `raise` saves who filed the item (an agent session id or any 1-128 of `A-Z a-z 0-9 . _ -`; `SIGN_OFF_FILER` sets it for every command, and a post with neither saves `CLAUDE_CODE_SESSION_ID` when it is set). `wait --filer <id>` then returns only answers to that filer's items it has not taken yet (with `--key`, that key's answer even if taken), marks each taken (`decisions.seen.jsonl`) and the card shows **Seen by agent**; it fails with `NOT_FOUND` when none of that filer's items still needs an answer. The page never shows the filer id.
163
+ - `on_answer: <command> [args]` in the `config.yaml` beside the board runs that command for every saved answer, read when the board starts. It runs with no shell, in the board's folder, and gets the answer as one JSON line on stdin (`{"event":"answer","contract":1,"kind","schema","key","answer","label","close","at","note"?,"title"?,"filer"?,"comments"?}`, where `kind` and `schema` are the item's contract kind, or `ask` and 1 for a board-format item, and `comments`, present when the answer carried comments on mock elements, lists each as `id`, `mock`, `hash`, `ref`, `tag`, `words` and `text`); the answer is never put into its arguments, so a typed note cannot inject a command. A failure is logged and never blocks the answer. With no shell, `|`, `>` and `&&` are passed as plain arguments, so put a pipeline in a script and name the script; a relative path such as `./hook.sh` is found from the board's folder. `doctor` checks that the command can run.
164
164
  - `post --from <file> --replace --filer <id>` lets several programs feed one board: it replaces only that filer's items (and items with no filer that the file names by key), keeps every other item, the board's other top-level fields and all answers, and an empty items list removes that filer's items.
165
165
  - `wait` replaces a polling loop. With `--key` it returns at once if that decision is already answered, and waits for a key that is not raised yet; without it, it waits for the next answer to any decision that still needs an answer and fails with `NOT_FOUND` when nothing needs one. It wakes on each save, gives up after `--timeout` seconds (600, ten minutes, by default) with code `TIMEOUT` and exit 1, and takes `--full` and `--json` like `answers`. Ctrl-C stops it without changing anything.
166
166
  - `open` records the board's pid, port and address next to the board file (`decisions.board.pid`). `status` and `stop` read it and only act on a pid that really serves that board, so no one has to guess a pid to kill. `open` on a board already served by `serve` records that server, so `stop` can close it too.
package/dist/cli.js CHANGED
@@ -29,7 +29,7 @@ const TOP_HELP = `usage: sign-off-axi [command] [board.json] [flags]
29
29
  commands[15]:
30
30
  (none)=list, raise, post, answers, wait, poll, open, status, stop, serve, export, init, doctor, validate, help
31
31
  board: the nearest .sign-off/ board (the walk's bounds and SIGN_OFF_HOME: README, "Test runs never touch your board"), else ~/.sign-off/decisions.json; a board file given always wins
32
- flags: --full and --json on list, answers and wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
32
+ flags: --full and --json on list, answers and wait, --events on wait, --json on status, --filer on post, raise and wait, --help on every command, -v/--version
33
33
  examples:
34
34
  sign-off-axi
35
35
  sign-off-axi raise --title "Ship the new sign-in screen this week?" --recommend yes
@@ -106,7 +106,7 @@ examples:
106
106
  sign-off-axi answers --key keep-the-weekly-digest
107
107
  sign-off-axi answers ./decisions.json --json
108
108
  `,
109
- wait: `usage: sign-off-axi wait [board.json] [--filer <id>] [--key <key>] [--timeout 600] [--full] [--json]
109
+ wait: `usage: sign-off-axi wait [board.json] [--filer <id>] [--key <key>] [--timeout 600] [--full] [--json | --events]
110
110
  Blocks until the user answers, then prints the answer the way answers does. Use it instead of a polling loop.
111
111
  --filer only the items posted with this --filer (or SIGN_OFF_FILER): returns every answer to them this filer has not taken yet,
112
112
  even one saved before wait started, and marks it taken, so the board shows "Seen by agent" on that card;
@@ -117,11 +117,15 @@ Blocks until the user answers, then prints the answer the way answers does. Use
117
117
  --timeout seconds to wait, 600 (10 minutes) by default; then exits 1 with code TIMEOUT
118
118
  --full adds the button label, close and title
119
119
  --json {"<key>":{"answer","label","close","at","note"?}} for the new answers; note only when the user wrote one
120
+ --events needs --filer (or SIGN_OFF_FILER); one JSON line per new answer and per another-round ask, in the order they were saved (${CONTRACT}#7-answers):
121
+ {"event":"answer","contract":1,"kind","schema","key","title","answer","label","close","at","note"?,"filer"?,"comments"?}
122
+ {"event":"round","contract":1,"kind","schema","key","title","note","at","filer"?,"comments"?}; a board-format item reports as ask schema 1
120
123
  A note column appears when any shown answer has a note.
121
124
  Ctrl-C stops waiting; nothing is changed.
122
125
  examples:
123
126
  sign-off-axi wait --key keep-the-weekly-digest
124
127
  sign-off-axi wait ./decisions.json --timeout 60 --json
128
+ sign-off-axi wait --filer agent-41 --events
125
129
  `,
126
130
  open: `usage: sign-off-axi open [board.json] [--port 4410] [--bind <address>] [--evidence-root <dir>]
127
131
  Starts the board in the background on 127.0.0.1 and prints its URL and pid; a no-op when this board is already open.
@@ -442,11 +446,32 @@ function answers(args) {
442
446
  const waiting = rows.filter(waits).length, key = values.key === undefined ? '' : ` --key ${values.key}`;
443
447
  return printAnswers(file, rows, waiting, values, waiting ? [`Run \`sign-off-axi open${on}\` so the user can answer the ${waiting} waiting`, `Run \`sign-off-axi wait${on}${key}\` to block until they answer`] : []);
444
448
  }
449
+ /** The contract fields an answer or round reports with: the item's kind and schema, or ask schema 1 for a board-format or dropped item (contract section 2). */
450
+ function kindOf(file) {
451
+ const items = new Map((existsSync(file) ? readItems(file).items : []).map((i) => [i?.key, i]));
452
+ return (key) => {
453
+ const i = items.get(key), contract = typeof i?.kind === 'string';
454
+ return { contract: 1, kind: contract ? i.kind : 'ask', schema: contract ? i.schema : 1 };
455
+ };
456
+ }
457
+ /** One JSON line per answer and per round (contract section 7), oldest first. */
458
+ function eventLines(file, rows, answers, rounds) {
459
+ const kind = kindOf(file), item = new Map(rows.map((d) => [d.key, d]));
460
+ const head = (event, key) => ({ event, ...kind(key), key, title: item.get(key)?.title });
461
+ const tail = (key, comments) => ({ ...(item.get(key)?.filer && { filer: item.get(key).filer }), ...(comments?.length && { comments }) });
462
+ return [...answers.map(({ key, answer, label, close, at, note, comments }) => ({ at, line: { ...head('answer', key), answer, label, close, at, ...(note && { note }), ...tail(key, comments) } })),
463
+ ...rounds.map(({ key, note, at, comments }) => ({ at, line: { ...head('round', key), note, at, ...tail(key, comments) } }))]
464
+ .sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0)).map(({ line }) => JSON.stringify(line)).join('\n');
465
+ }
445
466
  async function wait(args) {
446
- const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
467
+ const { file, values, on: onBoard } = parse('wait', args, { ...TIERS, events: { type: 'boolean' }, filer: { type: 'string' }, key: { type: 'string' }, timeout: { type: 'string', default: '600' }, into: { type: 'string' } });
447
468
  if (!/^\d{1,7}$/.test(values.timeout))
448
469
  throw fail('--timeout must be whole seconds, 0 or more', 'VALIDATION_ERROR', `Run \`sign-off-axi wait --help\``);
470
+ if (values.events && (values.json || values.full))
471
+ throw fail(`--events prints event lines, so it does not take --${values.json ? 'json' : 'full'}`, 'VALIDATION_ERROR', 'Run `sign-off-axi wait --filer <id> --events`');
449
472
  const filer = filerOf(values), on = onBoard + (filer ? ` --filer ${filer}` : '');
473
+ if (values.events && filer === undefined)
474
+ throw fail(`--events prints a filer's answer events, so it needs --filer`, 'VALIDATION_ERROR', 'Run `sign-off-axi wait --filer <id> --events`');
450
475
  // Vault Give delivery: its own loop, before any board read, so no ordinary wait changes. Undocumented while the vault is OFF.
451
476
  if (values.into !== undefined)
452
477
  return waitInto(file, values, onBoard);
@@ -471,22 +496,32 @@ async function wait(args) {
471
496
  for (let first = true;; first = false) {
472
497
  const rows = mine(board(file, first ? warn : () => { })), waiting = rows.filter(waits).length, taken = filer === undefined ? undefined : seenBy(file);
473
498
  const hit = rows.filter((d) => (values.key !== undefined ? d.key === values.key : d.answer && (taken ? !taken(d.answer) : !before.has(d.key))));
474
- const counts = new Map();
475
- for (const r of readRounds(file, first ? warn : () => { }))
499
+ const counts = new Map(), asked = [];
500
+ for (const r of readRounds(file, first ? warn : () => { })) {
476
501
  counts.set(r.key, (counts.get(r.key) ?? 0) + 1);
502
+ if (counts.get(r.key) > (seenRounds.get(r.key) ?? 0))
503
+ asked.push(r);
504
+ }
477
505
  const fresh = rows.filter((d) => (counts.get(d.key) ?? 0) > (seenRounds.get(d.key) ?? 0)
478
506
  && (values.key === undefined || d.key === values.key));
479
507
  for (const [k, n] of counts)
480
508
  seenRounds.set(k, n);
481
509
  let found = [...new Map([...hit.filter((d) => d.answer), ...fresh].map((d) => [d.key, d])).values()];
510
+ // The answers this wait hands over: a filer wait without --key gives only the ones it has not taken yet.
511
+ let given = hit.flatMap((d) => (d.answer ? [d.answer] : []));
482
512
  if (found.length && filer !== undefined) {
483
513
  // Taken under a lock, so two waits for one filer never both hand over the same answer (another-round asks are not deduped here).
484
514
  found = await locked(`${seenPath(file)}.lock`, () => {
485
515
  const now = seenBy(file), left = found.filter((d) => values.key !== undefined || fresh.includes(d) || !now(d.answer));
486
- markSeen(file, left.flatMap((d) => (d.answer && !now(d.answer) ? [d.answer] : [])));
516
+ const taking = left.flatMap((d) => (d.answer && !now(d.answer) ? [d.answer] : []));
517
+ markSeen(file, taking);
518
+ if (values.key === undefined)
519
+ given = taking;
487
520
  return left;
488
521
  }, 'another wait is still taking answers');
489
522
  }
523
+ if (found.length && values.events)
524
+ return eventLines(file, found, given, asked.filter((r) => fresh.some((d) => d.key === r.key)));
490
525
  if (found.length) {
491
526
  return printAnswers(file, found, waiting, values, [waiting ? `Run \`sign-off-axi wait${on}\` for the next of the ${waiting} waiting` : `Run \`sign-off-axi stop${onBoard}\` to close the board`]);
492
527
  }
@@ -1895,7 +1930,7 @@ function answered(a, d) {
1895
1930
  child.on('error', (e) => warn(`on_answer ${cmd} did not run: ${e.message}`));
1896
1931
  child.on('exit', (code, signal) => (code || signal) && warn(`on_answer ${cmd} exited with ${code ?? signal}`));
1897
1932
  child.stdin.on('error', () => { });
1898
- child.stdin.end(`${JSON.stringify({ ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
1933
+ child.stdin.end(`${JSON.stringify({ event: 'answer', ...kindOf(file)(a.key), ...a, ...(d && { title: d.title }), ...(d?.filer && { filer: d.filer }) })}\n`);
1899
1934
  }
1900
1935
  const server = createServer(async (req, res) => {
1901
1936
  const { port: bound } = server.address();
package/dist/vault.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { closeSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, isAbsolute, join, parse, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
+ import { isIP } from 'node:net';
5
6
  import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
6
7
  import { isKey } from './store.js';
7
8
  export const errors = {
@@ -27,7 +28,7 @@ export class VaultError extends Error {
27
28
  }
28
29
  function bad(code) { throw new VaultError(code); }
29
30
  const digest = (board, key) => createHash('sha256').update(JSON.stringify([board, key])).digest('hex');
30
- const aad = (meta) => Buffer.from(JSON.stringify([1, meta.board, meta.key, meta.mode, meta.expires, meta.destination ?? null, meta.filer]));
31
+ const aad = (meta) => Buffer.from(JSON.stringify([1, meta.board, meta.key, meta.mode, meta.expires, meta.destination ?? null, meta.filer, ...(meta.provider === undefined ? [] : [meta.provider])]));
31
32
  const VAULT_ID = /^[a-f0-9]{32}$/;
32
33
  function boardFile(board) {
33
34
  if (typeof board !== 'string' || !isAbsolute(board))
@@ -68,10 +69,10 @@ const plain = (p) => isAbsolute(p) && !p.split('/').some((s) => s === '.' || s =
68
69
  function metadata(m) {
69
70
  if (!m || typeof m.board !== 'string' || !VAULT_ID.test(m.board) || !isKey(m.key) || !isKey(m.filer) || !['give', 'receive'].includes(m.mode)
70
71
  || !Number.isSafeInteger(m.expires) || m.expires <= 0
71
- || (m.mode === 'give' ? typeof m.destination !== 'string' || !plain(m.destination) : m.destination !== undefined))
72
+ || (m.mode === 'give' ? typeof m.destination !== 'string' || !plain(m.destination) || m.provider !== undefined : m.destination !== undefined || !isKey(m.provider)))
72
73
  bad('VALIDATION_ERROR');
73
74
  return { board: m.board, key: m.key, mode: m.mode, filer: m.filer, expires: m.expires,
74
- ...(m.destination !== undefined && { destination: resolve(m.destination) }) };
75
+ ...(m.destination !== undefined && { destination: resolve(m.destination) }), ...(m.provider !== undefined && { provider: m.provider }) };
75
76
  }
76
77
  function privateFile(path) {
77
78
  const s = lstatSync(path);
@@ -138,6 +139,49 @@ function approvedDirs(file, dir) {
138
139
  return line;
139
140
  });
140
141
  }
142
+ // Owner-only list of approved Receive sites: `<provider> <https-origin>` per line, `#` comments. Any bad line denies all.
143
+ // Host rules live here; a link must then match one listed origin exactly, which carries them over.
144
+ function approvedProviders() {
145
+ const list = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'sign-off', 'vault-providers'), sites = new Map();
146
+ let text = '';
147
+ try {
148
+ if (isAbsolute(list)) {
149
+ privateFile(list);
150
+ text = readFileSync(list, 'utf8');
151
+ }
152
+ }
153
+ catch { /* denied below */ }
154
+ if (!text)
155
+ bad('VALIDATION_ERROR');
156
+ for (const line of text.split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'))) {
157
+ const [name, origin, extra] = line.split(/\s+/);
158
+ let host = '';
159
+ try {
160
+ const u = new URL(origin);
161
+ if (u.protocol === 'https:' && u.origin === origin && !u.port)
162
+ host = u.hostname;
163
+ }
164
+ catch { /* denied below */ }
165
+ if (extra !== undefined || !isKey(name) || sites.has(name) || !host.includes('.') || host.endsWith('.') || isIP(host))
166
+ bad('VALIDATION_ERROR');
167
+ sites.set(name, origin);
168
+ }
169
+ return sites;
170
+ }
171
+ /** A Receive link: one https URL whose exact origin the owner listed for its provider. Fails only with VALIDATION_ERROR, never echoing it. */
172
+ export function checkReceiveUrl(value, provider) {
173
+ const origin = approvedProviders().get(provider);
174
+ let url;
175
+ try {
176
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(value).replace(/\r?\n$/, '');
177
+ // The URL parser silently drops tabs and newlines and trims spaces, so any space or control is refused before it.
178
+ if (!/[\x00-\x20\x7f-\x9f]/.test(text) && Buffer.byteLength(text) <= 8192)
179
+ url = new URL(text);
180
+ }
181
+ catch { /* refused below */ }
182
+ if (!origin || !url || url.username || url.password || url.origin !== origin)
183
+ bad('VALIDATION_ERROR');
184
+ }
141
185
  function checkDestination(dest, file, dir) {
142
186
  if (!approvedDirs(file, dir).includes(dirname(dest)))
143
187
  bad('DESTINATION_DENIED');
@@ -489,6 +533,9 @@ export async function createVault(input, value) {
489
533
  bad('VALIDATION_ERROR');
490
534
  if ((fixed.mode === 'receive') !== (value !== undefined) || (value !== undefined && !sized(value)))
491
535
  bad('VALIDATION_ERROR');
536
+ // Before custody: a refused link leaves no folder, folder id, key or record.
537
+ if (fixed.mode === 'receive')
538
+ checkReceiveUrl(value, fixed.provider);
492
539
  return operate(input.board, fixed.key, true, (dir, path, old, id) => {
493
540
  const meta = { ...fixed, board: id };
494
541
  if (old)
@@ -554,17 +601,21 @@ export async function deliverVault(board, key, filer, into, afterClaim) {
554
601
  try {
555
602
  afterClaim?.();
556
603
  const value = unseal(secret, r.meta, r.sealed);
557
- const fd = openSync(dest, 'wx', 0o600);
558
604
  try {
559
- const st = fstatSync(fd);
560
- made = { dev: st.dev, ino: st.ino };
561
- writeFileSync(fd, value);
562
- fsyncSync(fd);
605
+ const fd = openSync(dest, 'wx', 0o600);
606
+ try {
607
+ const st = fstatSync(fd);
608
+ made = { dev: st.dev, ino: st.ino };
609
+ writeFileSync(fd, value);
610
+ fsyncSync(fd);
611
+ }
612
+ finally {
613
+ closeSync(fd);
614
+ }
563
615
  }
564
616
  finally {
565
- closeSync(fd);
566
617
  value.fill(0);
567
- }
618
+ } // also when openSync or closeSync throws
568
619
  syncDir(dirname(dest));
569
620
  const done = { schema: 1, meta: r.meta, status: 'delivered', at: Date.now() };
570
621
  publish(path, JSON.stringify(done));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sign-off-axi",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "One review board for evidence, quick yes/no decisions and retro changes.",
5
5
  "type": "module",
6
6
  "bin": { "sign-off-axi": "dist/cli.js" },