@ctrl-spc/cs 0.7.3 → 0.7.5

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/dist/mcp.js CHANGED
@@ -14,7 +14,12 @@ import { z } from 'zod';
14
14
  import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
15
15
  import { agentPath } from './agents.js';
16
16
  import { mcpToken, readMcpToken, readSession } from './config.js';
17
- import { readPngScreenshot, } from './screenshots.js';
17
+ import { readableWriteError, FIREWALL_WRITING_RULE } from './firewall.js';
18
+ /* The path rule itself, moved out so `workflows.ts` can ask the same question
19
+ before it builds a workflow. `refuseAbsolutePaths` below is still v2's own
20
+ wrapper: it is the one that answers in a `CallToolResult`. */
21
+ import { ABSOLUTE_PATH_RE, absolutePathToken } from './local-paths.js';
22
+ import { readPngScreenshot, screenshotArtifactId, screenshotRequestId, } from './screenshots.js';
18
23
  /* 18c Slice 7 correction: the product captures the PNG itself, because a
19
24
  spawned worker has no reachable way to run a capture command. The whole
20
25
  argument is on `captureUrlScreenshot`. */
@@ -239,7 +244,7 @@ function validateGrounding(value) {
239
244
  async function must(query) {
240
245
  const { data, error } = await query;
241
246
  if (error)
242
- throw new Error(error.message);
247
+ throw new Error(readableWriteError(error.message));
243
248
  return data;
244
249
  }
245
250
  /** The message of a thrown value, WITHOUT assuming it is an `Error`. `(err as
@@ -987,50 +992,6 @@ const SCREENSHOT_PLATFORM_LABEL = {
987
992
  function attachScreenshotFailure(title, reason, created = 'artifact') {
988
993
  return errorResult(`Couldn’t attach "${title}": ${reason}. No ${created} was created.`);
989
994
  }
990
- /** 18c Slice 7. The deterministic id for a REQUEST-anchored screenshot.
991
- *
992
- * IT IS THE SAME DERIVATION WITH A DIFFERENT ANCHOR AND A DIFFERENT DOMAIN
993
- * TAG, and both halves of that matter. Same derivation, because the property it
994
- * buys is the one the work-item path already needs: an interrupted call that
995
- * uploaded the object but never wrote the row retries onto the SAME key and
996
- * converges, instead of leaking one private object per attempt. Different
997
- * domain tag (`:todo:`), because the two anchors must not be able to collide —
998
- * a task id and a request id are both uuids, and without the tag a screenshot
999
- * with the same title, platform, target and bytes could derive one id under two
1000
- * anchors and have the second insert fail against the first's object. */
1001
- export function screenshotRequestId(todoId, title, platform, target, bytes) {
1002
- return screenshotDeterministicId('ctrl-spc:screenshot:todo:v1\0', todoId, title, platform, target, bytes);
1003
- }
1004
- export function screenshotArtifactId(taskId, title, platform, target, bytes) {
1005
- return screenshotDeterministicId('ctrl-spc:screenshot:v1\0', taskId, title, platform, target, bytes);
1006
- }
1007
- /** The shared body of the two id derivations above. Extracted rather than
1008
- * duplicated so the two anchors cannot drift into hashing different things:
1009
- * the whole point of a content-addressed id is that the same picture yields the
1010
- * same key, and two copies of this would eventually disagree about what "the
1011
- * same picture" means. `domain` is what keeps them distinct. */
1012
- function screenshotDeterministicId(domain, anchorId, title, platform, target, bytes) {
1013
- const digest = createHash('sha256')
1014
- .update(domain)
1015
- .update(anchorId)
1016
- .update('\0')
1017
- .update(title)
1018
- .update('\0')
1019
- .update(platform)
1020
- .update('\0')
1021
- .update(target)
1022
- .update('\0')
1023
- .update(bytes)
1024
- .digest()
1025
- .subarray(0, 16);
1026
- // RFC 9562-shaped, deterministic UUID. The content-addressed identity makes
1027
- // a retry after Storage succeeded but Postgres failed converge on the same
1028
- // object instead of leaking one new object per retry.
1029
- digest[6] = (digest[6] & 0x0f) | 0x50;
1030
- digest[8] = (digest[8] & 0x3f) | 0x80;
1031
- const hex = digest.toString('hex');
1032
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1033
- }
1034
995
  function isDuplicateStorageObject(error) {
1035
996
  return /duplicate|already exists|resource exists/i.test(`${error.message} ${error.error ?? ''} ${error.name ?? ''}`);
1036
997
  }
@@ -2058,11 +2019,8 @@ async function resolvePlacementTarget(client, table, noun, id, projectId) {
2058
2019
  * `epic_id` / `sprint_id` since 20260727080000 — with the fresh row's
2059
2020
  * revision, so the CAS contract is honored rather than bypassed.
2060
2021
  *
2061
- * Sequencing fix: the create RPC's own position (min-1, top of backlog) is
2062
- * right for a single human quick-add but inverts an agent-authored sequence,
2063
- * so the placement pass runs for EVERY creation and moves the task to the
2064
- * project's max(position)+1 — creation order equals board order. The
2065
- * `position` key rides the same save_task_if_current p_changes whitelist.
2022
+ * The create_task RPC appends at the bottom of the backlog, so creation order
2023
+ * equals board order with no follow-up write.
2066
2024
  */
2067
2025
  export async function createTaskHandler(client, userId, currentSession, args,
2068
2026
  /** 18c Slice 6: the request THIS CONNECTION is working. See
@@ -2083,25 +2041,6 @@ connectionTodoId = null) {
2083
2041
  if (!sprint.ok)
2084
2042
  return sprint.error;
2085
2043
  }
2086
- // Feature 32 sequencing: the create_task RPC assigns position = min-1 (top
2087
- // of backlog — right for a single human quick-add), so N sequential agent
2088
- // creates would render NEWEST-FIRST on the board, inverting the skeleton's
2089
- // sequence. Read the project's current max position BEFORE the create so
2090
- // the placement pass below can APPEND the fresh task at max+1 — for every
2091
- // creation, placed or not, so creation order equals board order.
2092
- // (save_task_if_current's p_changes whitelist accepts `position`.) A
2093
- // concurrent-agent race on max+1 costs at worst an adjacent swap.
2094
- const maxPositionRow = await must(client
2095
- .from('tasks')
2096
- .select('position')
2097
- .eq('project_id', args.project_id)
2098
- .is('archived_at', null)
2099
- .order('position', { ascending: false })
2100
- .limit(1)
2101
- .maybeSingle());
2102
- const nextPosition = (typeof maxPositionRow?.position === 'number' && Number.isFinite(maxPositionRow.position)
2103
- ? maxPositionRow.position
2104
- : 0) + 1;
2105
2044
  const taskId = await must(client.rpc('create_task', {
2106
2045
  p_project: args.project_id,
2107
2046
  p_name: args.name,
@@ -2113,37 +2052,37 @@ connectionTodoId = null) {
2113
2052
  let task = await fetchTask(client, taskId);
2114
2053
  if (!task)
2115
2054
  throw new Error('Created task could not be re-fetched.');
2116
- // Place the fresh task on the board: append it at the project's max+1
2117
- // position (EVERY create see the sequencing note above), plus its
2118
- // epic/sprint when given. The task row is COMMITTED by this point, so a
2119
- // placement failure is surfaced as a warning on the real result —
2120
- // reporting it as a failed create would make the agent retry and
2121
- // duplicate the task (the create_product_idea read-back lesson).
2055
+ // Place the fresh task in its epic/sprint when either was given. The task
2056
+ // row is COMMITTED by this point, so a placement failure is surfaced as a
2057
+ // warning on the real result: reporting it as a failed create would make
2058
+ // the agent retry and duplicate the task (the create_product_idea
2059
+ // read-back lesson).
2122
2060
  let placementWarning;
2123
- try {
2124
- const revision = task.revision;
2125
- if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1) {
2126
- throw new Error('the created task carried no usable revision');
2061
+ if (args.epic_id !== undefined || args.sprint_id !== undefined) {
2062
+ try {
2063
+ const revision = task.revision;
2064
+ if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1) {
2065
+ throw new Error('the created task carried no usable revision');
2066
+ }
2067
+ await must(client.rpc('save_task_if_current', {
2068
+ p_task_id: taskId,
2069
+ p_expected_revision: revision,
2070
+ p_changes: {
2071
+ ...(args.epic_id !== undefined ? { epic_id: args.epic_id } : {}),
2072
+ ...(args.sprint_id !== undefined ? { sprint_id: args.sprint_id } : {}),
2073
+ },
2074
+ p_tag_ids: null,
2075
+ p_reorder: false,
2076
+ p_before_task_id: null,
2077
+ }));
2078
+ task = (await fetchTask(client, taskId)) ?? task;
2079
+ }
2080
+ catch (err) {
2081
+ placementWarning =
2082
+ `The task was created (id ${taskId}) but placing it on the board (its epic/sprint) failed: ` +
2083
+ `${errorMessage(err)}. Do NOT create the task again; report the failed placement, ` +
2084
+ 'a human can place it from the board.';
2127
2085
  }
2128
- await must(client.rpc('save_task_if_current', {
2129
- p_task_id: taskId,
2130
- p_expected_revision: revision,
2131
- p_changes: {
2132
- position: nextPosition,
2133
- ...(args.epic_id !== undefined ? { epic_id: args.epic_id } : {}),
2134
- ...(args.sprint_id !== undefined ? { sprint_id: args.sprint_id } : {}),
2135
- },
2136
- p_tag_ids: null,
2137
- p_reorder: false,
2138
- p_before_task_id: null,
2139
- }));
2140
- task = (await fetchTask(client, taskId)) ?? task;
2141
- }
2142
- catch (err) {
2143
- placementWarning =
2144
- `The task was created (id ${taskId}) but placing it on the board (its position/epic/sprint) failed: ` +
2145
- `${errorMessage(err)}. Do NOT create the task again — report the failed placement; ` +
2146
- 'a human can place it from the board.';
2147
2086
  }
2148
2087
  // D2a attribution: if this connection has an open session, record that the
2149
2088
  // session produced this NEW task — one cliv2_agent_outputs row referencing the
@@ -6071,19 +6010,6 @@ const MAX_PROPOSALS = 50;
6071
6010
  * make a legal document un-acceptable — so the BATCH is what is bounded, which
6072
6011
  * is the thing that actually protects the write. */
6073
6012
  const MAX_PROPOSAL_BATCH_CHARS = 400_000;
6074
- /** Absolute in any form a repo path can arrive in: POSIX (`/etc`), UNC and
6075
- * root-relative Windows (`\\server\share`, `\Users\Lane\repo`), Windows
6076
- * drive-letter (`C:\repo`, `C:/repo`) and drive-RELATIVE (`C:AGENTS.md`, which
6077
- * resolves against that drive's current directory and still discloses a local
6078
- * layout). One leading separator of EITHER kind, or any drive letter, is
6079
- * enough — a single leading backslash is a Windows absolute path just as `/`
6080
- * is a POSIX one.
6081
- *
6082
- * `cliv2_work_reservations_path_relative`
6083
- * (20260722160000_cliv2_coordination.sql) is the same rule for the same
6084
- * reason, and it has the narrower form with both of those holes. That is a
6085
- * pre-existing bug on that table, not a licence to repeat it here. */
6086
- const ABSOLUTE_PATH_RE = /^(?:[\\/]|[A-Za-z]:)/;
6087
6013
  /** A `..` segment under either separator. A relative path that climbs out of
6088
6014
  * the scanned repo names a file the scan had no business reading, and it is
6089
6015
  * refused for the same privacy reason an absolute path is. */
@@ -7698,220 +7624,6 @@ const STEP_SOURCE_KINDS = ['workflow', 'plan'];
7698
7624
  * sets the opening value; `update_step` writes every transition after it —
7699
7625
  * including 'done', which is the only one `cliv2_steps_up_next` advances on. */
7700
7626
  const STEP_STATUSES = ['pending', 'in_progress', 'blocked', 'interrupted', 'done'];
7701
- /** Decode the HTML entities a browser would decode, so the guard scans what
7702
- * the USER will eventually read rather than what the agent happened to type.
7703
- * Without this, `&#47;Users&#47;lane&#47;x` sails past every scan and then
7704
- * `interactiveArtifactHtml()` — which parses with DOMParser — paints the
7705
- * real absolute path into hosted browser JS. The guard and the renderer must
7706
- * agree about what a string SAYS; entities are exactly where they diverge.
7707
- * Numeric (decimal and hex) plus the small set of named entities that can
7708
- * spell a path separator or a drive colon. */
7709
- const NAMED_ENTITIES = {
7710
- sol: '/',
7711
- bsol: '\\',
7712
- colon: ':',
7713
- period: '.',
7714
- lowbar: '_',
7715
- quot: '"',
7716
- apos: "'",
7717
- amp: '&',
7718
- lt: '<',
7719
- gt: '>',
7720
- };
7721
- /* The trailing `;` is OPTIONAL for numeric entities, because browsers accept
7722
- it that way: `&#47Users` renders as `/Users`. A guard that required the
7723
- semicolon allowed exactly that string through while the renderer painted a
7724
- real path — found by differentially testing this function against jsdom's
7725
- DOMParser, which is the same parser interactiveArtifactHtml() uses. Named
7726
- entities keep the required `;` (that is what browsers do outside a short
7727
- legacy list, and dropping it would eat `&sole` in ordinary prose). */
7728
- function decodeEntities(text) {
7729
- return text.replace(/&(?:(#[Xx][0-9A-Fa-f]+|#\d+);?|([A-Za-z][A-Za-z0-9]*);)/g, (whole, numericBody, namedBody) => {
7730
- const body = numericBody ?? namedBody ?? '';
7731
- if (body.startsWith('#')) {
7732
- const code = body[1] === 'x' || body[1] === 'X'
7733
- ? Number.parseInt(body.slice(2), 16)
7734
- : Number.parseInt(body.slice(1), 10);
7735
- return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
7736
- }
7737
- // Named entities are case-SENSITIVE in HTML: `&Colon;` is U+2237 (∷), not
7738
- // a colon, so lower-casing here refused text the browser never renders as
7739
- // a path. Exact match only.
7740
- const named = NAMED_ENTITIES[body];
7741
- return named ?? whole;
7742
- });
7743
- }
7744
- /**
7745
- * The first absolute local path inside `text`, or null.
7746
- *
7747
- * THE DESIGN, and why it is not the previous one. This helper used to split
7748
- * the text into tokens on a separator list and test each token. Three review
7749
- * rounds each answered a leak by adding characters to that list (`(,=;`, then
7750
- * `<>"'`), and the third round proved the approach is wrong rather than
7751
- * incomplete: a quote *inside* a path (`/'Users/lane/x`, `/Users/lane/Lane's
7752
- * Docs/x`) SPLIT the path into fragments, none of which starts with a root
7753
- * marker — so widening the separator list to catch markup simultaneously
7754
- * opened a hole for paths containing that markup, and the refusal message for
7755
- * a legitimate apostrophe path named a truncated path that did not exist.
7756
- * Separators cannot both be inside and outside the thing being matched.
7757
- *
7758
- * So: do not tokenise. SCAN for the path shape itself, anchored at a root
7759
- * marker, and let the match end where a character that cannot appear in a
7760
- * path appears. A path is a match, not a token. The exclusions below are the
7761
- * genuine false positives, tested against the same corpus as before:
7762
- *
7763
- * - a URL's authority (`https://host/path`) is not a local path — a match
7764
- * immediately preceded by `//` of a non-`file:` scheme is skipped, while
7765
- * `file:///Users/…` IS reported (its body is a real local path);
7766
- * - a bare drive label `A:` with no path body is a list marker;
7767
- * - a single-segment `/word` (`/dashboard`, `/api/`) is a URL path — local
7768
- * paths in prose always carry a second separator.
7769
- *
7770
- * Entities are decoded first (see `decodeEntities`), because the renderer
7771
- * decodes them too.
7772
- */
7773
- /* The scan finds the path SHAPE anywhere in the text and decides what it is
7774
- from WHAT MATCHED, not from what precedes it.
7775
- *
7776
- * A previous round anchored this with a lookbehind excluding "characters a
7777
- * path cannot follow". That was the separator-list mistake one layer down: the
7778
- * exclusion set is itself a character list, and every character in it became a
7779
- * hiding place. `~/Users/lane/x`, `x./Users/lane/x` and a path embedded in a
7780
- * URL all leaked, and `~/…` is an entirely ordinary thing for an agent to
7781
- * write. A rule that says "not after these characters" can always be defeated
7782
- * by writing one of them first.
7783
- *
7784
- * So there is no lookbehind. Instead the two genuine false positives are
7785
- * excluded by structure, below:
7786
- * - a repo-relative path (`web/src/App.tsx`) never starts at a root marker,
7787
- * so requiring the match to BEGIN with `/`, `\\` or `C:` already excludes
7788
- * it — `/src/App.tsx` inside it is only reachable mid-token, which the
7789
- * single-segment and second-separator rules then handle;
7790
- * - a URL's authority is recognised by its own scheme, checked explicitly.
7791
- *
7792
- * `'` and `"` are NOT body terminators — a quote inside a path is exactly the
7793
- * case that made the separator-splitting design leak. */
7794
- const ABSOLUTE_PATH_SCAN = /(?:[A-Za-z]:[\\/'"]|\\\\|\/)[^\s<>`(){}\[\],;|&*?\n\r\t]*/g;
7795
- /* The POSIX roots that actually hold a user's files. A leading `/` alone does
7796
- NOT make a machine path — `/api/items`, `/img/logo.png` and `/dashboard` are
7797
- site paths, and an agent's panel is full of them (href, src, srcset, CSS
7798
- url()). Refusing those would make the house style unusable and agents would
7799
- route around the guard, which is worse than a narrower rule. These roots are
7800
- the ones whose contents are private to the machine. */
7801
- const POSIX_MACHINE_ROOT = /^\/(?:Users|home|root|var|etc|opt|srv|private|tmp|mnt|media|Volumes|Applications|Library|System|usr\/local|dev)(?:\/|$)/i;
7802
- /** A machine path buried inside a longer match — the segment of a remote URL
7803
- * that spells one (`https://host/x/Users/lane/secret.txt`). The scan's body
7804
- * is greedy, so such a path never gets a match of its own; it has to be dug
7805
- * out of the match that swallowed it. Rendered in front of a viewer, it
7806
- * discloses exactly what bare text would. */
7807
- function buriedMachinePath(candidate) {
7808
- // A Windows path can be buried too — `https://host/xC:\Users\Lane\x` — and
7809
- // it does not begin at a `/`, so scanning only slash positions missed it.
7810
- // Shape, not just a colon-slash: a drive letter, a separator, then at least
7811
- // one real segment. `a:/b` in a URL query is not a Windows path; the `\` or
7812
- // a `Users`-style segment is what makes it one. Requiring a BACKSLASH
7813
- // separator keeps this narrow — a forward-slash drive path (`C:/x`) inside a
7814
- // URL is indistinguishable from an ordinary URL fragment, and the POSIX pass
7815
- // below already covers the roots that matter.
7816
- const drive = /[A-Za-z]:\\[^\s<>"'`]+/.exec(candidate);
7817
- if (drive)
7818
- return candidate.slice(drive.index);
7819
- for (let at = candidate.indexOf('/'); at !== -1; at = candidate.indexOf('/', at + 1)) {
7820
- const tail = candidate.slice(at);
7821
- if (POSIX_MACHINE_ROOT.test(tail))
7822
- return tail;
7823
- }
7824
- return null;
7825
- }
7826
- function absolutePathToken(text) {
7827
- const decoded = decodeEntities(text);
7828
- // `file:///Users/lane/x` is a local path wearing a URL. Handle it up front
7829
- // and by NAME, rather than leaving the generic scan to reason about where a
7830
- // scheme ends — that reasoning is what produced two of this function's bugs.
7831
- const fileUrl = /\bfile:\/\/(\/\S*)/i.exec(decoded);
7832
- if (fileUrl && POSIX_MACHINE_ROOT.test(fileUrl[1]))
7833
- return fileUrl[1];
7834
- ABSOLUTE_PATH_SCAN.lastIndex = 0;
7835
- let match;
7836
- while ((match = ABSOLUTE_PATH_SCAN.exec(decoded)) !== null) {
7837
- // Trailing wrapping punctuation belongs to the prose, not the path:
7838
- // `("/Users/lane/x.md")` and `see /Users/lane/x.md.` both end early.
7839
- const candidate = match[0].replace(/["')\].,:;]+$/, '');
7840
- if (!candidate || !ABSOLUTE_PATH_RE.test(candidate))
7841
- continue;
7842
- const before = decoded.slice(0, match.index);
7843
- // A URL: skip its authority and its ordinary path — `https://host/a/b`
7844
- // matches at `//host/a/b` and again at `/b`, and neither is a local path.
7845
- // The scheme identifies it, so look for the scheme rather than testing the
7846
- // character immediately before. (`file:` never reaches here; it is handled
7847
- // by name above.)
7848
- //
7849
- // EXCEPT when the URL's path is itself rooted at a machine root. A remote
7850
- // URL spelling `…/x/Users/lane/private/secret.txt` puts that path in front
7851
- // of a viewer just as plainly as bare text does, and the contract is about
7852
- // the string reaching hosted browser JS, not about how it got there. A
7853
- // false alarm costs the agent one retry; a leak is permanent, so this errs
7854
- // toward refusing.
7855
- if (/([A-Za-z][A-Za-z0-9+.-]*):\/\/\S*$/.test(before) && !POSIX_MACHINE_ROOT.test(candidate)) {
7856
- const buried = buriedMachinePath(candidate);
7857
- if (buried)
7858
- return buried;
7859
- continue;
7860
- }
7861
- // `https://host/x` also matches AT the scheme's own `s://…`, which reads
7862
- // as a one-letter drive. A real drive letter is followed by a separator
7863
- // and then a path segment — never by `//`. Before discarding it, check
7864
- // whether a machine root is buried INSIDE: the scan's body is greedy, so
7865
- // `s://example.com/x/Users/lane/x` is one match and the `/Users/…` inside
7866
- // it never gets a match of its own. A remote URL spelling a machine path
7867
- // discloses it to a viewer just as plainly as bare text does.
7868
- if (/^[A-Za-z]:\/\//.test(candidate)) {
7869
- const buried = buriedMachinePath(candidate);
7870
- if (buried)
7871
- return buried;
7872
- continue;
7873
- }
7874
- // A CONTINUATION of a relative path: `web/src/App.tsx` matches at
7875
- // `/src/App.tsx`, which is not a local path — the token it belongs to
7876
- // began with a bare word. Walk back to the start of the whole token and
7877
- // ask what IT begins with. This is deliberately not "is the preceding
7878
- // character in a set": `~/Users/lane/x` and `x./Users/lane/x` both have a
7879
- // path-ish character before the slash, and both DO carry a real absolute
7880
- // path, so a character test leaks them. Reading the token's own first
7881
- // character answers correctly in every one of those cases.
7882
- // The token runs back to whitespace OR a markup boundary (`<>"'=`) — a
7883
- // quote or a tag bracket ends the token even though it is not whitespace,
7884
- // which is what lets `class="mono">/Users/…` be seen as a path rather than
7885
- // as the tail of the token `class`.
7886
- // Only a token that is itself a plausible RELATIVE PATH suppresses the
7887
- // match — `web/src` in `web/src/App.tsx`. A bare word (`x.`), a number
7888
- // (`1`), or anything not shaped like a path segment does not, because
7889
- // `x./Users/lane/x` and `1/Users/lane/x` still carry a real absolute path.
7890
- const tokenStart = /([^\s<>"'=]+)$/.exec(before)?.[1] ?? '';
7891
- if (/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9._-]+)*\/?$/.test(tokenStart)
7892
- && tokenStart.includes('/')
7893
- && !POSIX_MACHINE_ROOT.test(candidate))
7894
- continue;
7895
- if (/^[A-Za-z]:$/.test(candidate))
7896
- continue;
7897
- // A POSIX candidate is only a MACHINE path if it starts at a real root.
7898
- // `/img/x.png`, `/api/items`, `/dashboard` and `2026/07/31` are site paths
7899
- // and dates — they are not local paths, they are the ordinary content of
7900
- // an agent's panel (CSS urls, hrefs, srcset), and refusing them makes the
7901
- // house style unusable. The roots below are the ones that actually carry a
7902
- // user's files; a path under any of them is refused, everything else is
7903
- // left alone. Windows (`C:\…`) and UNC (`\\host\share`) are unambiguous by
7904
- // construction and are not filtered here.
7905
- // Strip a quote sitting immediately after the root before classifying:
7906
- // `/'Users/lane/x` is the separator-design leak, and the root test must
7907
- // see `/Users/…` to recognise it.
7908
- const rooted = candidate.replace(/^([\\/]+)["']/, '$1');
7909
- if (rooted.startsWith('/') && !POSIX_MACHINE_ROOT.test(rooted))
7910
- continue;
7911
- return candidate;
7912
- }
7913
- return null;
7914
- }
7915
7627
  /** Refuse the WHOLE call when any field carries an absolute local path —
7916
7628
  * naming the field and the offending token, so the agent can fix exactly
7917
7629
  * that. Refusal happens before any write; nothing is rewritten. */
@@ -10015,7 +9727,9 @@ const SERVER_INSTRUCTIONS = `You are connected to CTRL+SPC, where this user's wo
10015
9727
 
10016
9728
  3. KEEP THE TERMINAL TERSE. The user is watching CTRL+SPC, not this conversation. Do not narrate, do not paste long output, do not hold the discussion here. Say in one or two lines what you did and point them at CTRL+SPC to see it. Ask questions through ask_question so the answer is recorded, not through terminal prose that vanishes.
10017
9729
 
10018
- The terminal conversation disappears when the process does. What you put in CTRL+SPC is what survives.`;
9730
+ The terminal conversation disappears when the process does. What you put in CTRL+SPC is what survives.
9731
+
9732
+ Everything you write into CTRL+SPC is saved the same way, whichever tool you use. ${FIREWALL_WRITING_RULE}`;
10019
9733
  export function buildToolsServer(client, userId, machineId, connectionId,
10020
9734
  /** 18c SLICE 1 — the request (`cliv2_loose_todos.id`) this connection is
10021
9735
  * working, from the connection's own URL rather than a tool argument the
@@ -10079,7 +9793,8 @@ runTodoIdSource = null) {
10079
9793
  + 'client (get_client_context lists them under `projects` — one project resolves the choice itself, several means '
10080
9794
  + "you choose and say why). Ground the idea in the client's own words in a CITED ARTIFACT on it "
10081
9795
  + '(create_artifact) — the description is the human\'s plain-text surface and carries no citation lines. '
10082
- + CITATION_LINE_TEACHING,
9796
+ + CITATION_LINE_TEACHING + ' '
9797
+ + FIREWALL_WRITING_RULE,
10083
9798
  inputSchema: {
10084
9799
  project_id: z.string(),
10085
9800
  title: z
@@ -10133,7 +9848,8 @@ runTodoIdSource = null) {
10133
9848
  '`grounding` naming exactly what you read. The manifest is stored with the artifact and its ' +
10134
9849
  'absence is visible to every reader: a user_story written WITHOUT reading available client ' +
10135
9850
  'context must say so in its opening line. ' +
10136
- CITATION_LINE_TEACHING,
9851
+ CITATION_LINE_TEACHING + ' ' +
9852
+ FIREWALL_WRITING_RULE,
10137
9853
  inputSchema: {
10138
9854
  task_id: z.string(),
10139
9855
  type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe', 'user_story']),
@@ -10226,7 +9942,8 @@ runTodoIdSource = null) {
10226
9942
  + 'That is not a default you can be talked out of: if the user asks you to rewrite a description that has content, say you cannot and offer the artifact, rather than offering to do it anyway. '
10227
9943
  + 'Update a task you own — its status (backlog / in_progress / done), name, description, or due_date. ' +
10228
9944
  'Pass expected_revision from the most recent get_task for optimistic concurrency; on a conflict, ' +
10229
- 'call get_task again to re-read and retry. The change appears in the web board.',
9945
+ 'call get_task again to re-read and retry. The change appears in the web board. ' +
9946
+ FIREWALL_WRITING_RULE,
10230
9947
  inputSchema: {
10231
9948
  id: z.string().describe('Task id'),
10232
9949
  expected_revision: z
@@ -10248,7 +9965,8 @@ runTodoIdSource = null) {
10248
9965
  server.registerTool('update_artifact', {
10249
9966
  description: 'Edit an artifact you can access — its title, content, type, or format. Pass expected_revision from the ' +
10250
9967
  'most recent get_task (optimistic concurrency); on a conflict, call get_task again and retry. ' +
10251
- 'The change appears in the web UI.',
9968
+ 'The change appears in the web UI. ' +
9969
+ FIREWALL_WRITING_RULE,
10252
9970
  inputSchema: {
10253
9971
  id: z.string(),
10254
9972
  expected_revision: z
@@ -10320,7 +10038,8 @@ runTodoIdSource = null) {
10320
10038
  return setTaskRoleSlugsHandler(client, args);
10321
10039
  });
10322
10040
  server.registerTool('add_comment', {
10323
- description: 'Leave a note on a task. The comment appears in the web UI, authored by you.',
10041
+ description: 'Leave a note on a task. The comment appears in the web UI, authored by you. ' +
10042
+ FIREWALL_WRITING_RULE,
10324
10043
  inputSchema: {
10325
10044
  task_id: z.string().describe('Task id'),
10326
10045
  body: z.string().min(1),
@@ -10340,7 +10059,8 @@ runTodoIdSource = null) {
10340
10059
  'READ BEFORE YOU WRITE: list what exists (list_tasks) before adding structure, and cut items ' +
10341
10060
  'along real seams — separately buildable, separately testable — not one item per noun in the ' +
10342
10061
  'ask. Add NO duration estimates to names or descriptions unless the user asked: agents ' +
10343
- 'overestimate toward human timelines, and sequencing, not duration, is the value.',
10062
+ 'overestimate toward human timelines, and sequencing, not duration, is the value. ' +
10063
+ FIREWALL_WRITING_RULE,
10344
10064
  inputSchema: {
10345
10065
  project_id: z.string().describe('Project id the task belongs to'),
10346
10066
  name: z.string().min(1),
@@ -11112,7 +10832,8 @@ runTodoIdSource = null) {
11112
10832
  'present_wireframes ' +
11113
10833
  'again for iterations of the same design. Requires an open session (begin_work). If diagrams are created ' +
11114
10834
  'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
11115
- '— follow them instead of calling this tool again.',
10835
+ '— follow them instead of calling this tool again. ' +
10836
+ FIREWALL_WRITING_RULE,
11116
10837
  inputSchema: {
11117
10838
  title: z.string().min(1).describe('Short title for the presentation (shown on the work item)'),
11118
10839
  kind: z.enum(['ui', 'architecture']).describe("'ui' for wireframes, 'architecture' for code/flow diagrams"),
@@ -11162,7 +10883,8 @@ runTodoIdSource = null) {
11162
10883
  'present_mocks ' +
11163
10884
  'again for iterations of the same design. Requires an open session (begin_work). If mocks are created ' +
11164
10885
  'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
11165
- '— follow them instead of calling this tool again.',
10886
+ '— follow them instead of calling this tool again. ' +
10887
+ FIREWALL_WRITING_RULE,
11166
10888
  // This schema DECLARES every field the handler reads and CONSTRAINS none
11167
10889
  // of what the handler validates — the two halves of the Phase 1 defect.
11168
10890
  // DECLARE, because the SDK parses args with z.object(inputSchema) and
@@ -11247,7 +10969,8 @@ runTodoIdSource = null) {
11247
10969
  });
11248
10970
  server.registerTool('record_context_exploration', {
11249
10971
  description: "Record what you explored for this work item as its context document (the canonical 'Work item context' " +
11250
- 'for the item). Repeated calls update the same document. Appears in the web UI.',
10972
+ 'for the item). Repeated calls update the same document. Appears in the web UI. ' +
10973
+ FIREWALL_WRITING_RULE,
11251
10974
  inputSchema: {
11252
10975
  content: z.string().min(1),
11253
10976
  },
@@ -11476,7 +11199,8 @@ runTodoIdSource = null) {
11476
11199
  'writes proposals the user accepts or rejects in the web app, under Project settings → Project ' +
11477
11200
  'context. Say so when you report back. Re-running replaces this codebase\'s pending proposals; send ' +
11478
11201
  'an empty `proposals` array to report that the repo holds no standing context, which clears them. ' +
11479
- "Uses the open session's task when `task_id` is omitted.",
11202
+ "Uses the open session's task when `task_id` is omitted. " +
11203
+ FIREWALL_WRITING_RULE,
11480
11204
  inputSchema: {
11481
11205
  task_id: z
11482
11206
  .string()
@@ -12317,8 +12041,26 @@ function sessionForAttribution(connectionId) {
12317
12041
  * token the presence loop rebuilt after a wedge/refresh flows into the session
12318
12042
  * heartbeat too (FIX 2 — the CLI-v1 stale-token root cause). Only takes effect
12319
12043
  * while the tools server is running; the per-connection tool handlers keep their
12320
- * own captured client, which is acceptable the session heartbeat is what must
12321
- * stay healthy for the presence chip. */
12044
+ * own captured client, which is acceptable here, because the session heartbeat
12045
+ * is what must stay healthy for the presence chip.
12046
+ *
12047
+ * THAT REASONING IS LOCAL TO THIS FILE AND DOES NOT CARRY ACROSS. A `panel3`
12048
+ * tool handler serves project writes rather than a heartbeat, so a captured
12049
+ * client there is a refused write against real work, not a chip that goes grey.
12050
+ * `panel3/tools.ts` takes its client by value and fans it out to every handler
12051
+ * for that connection's life, and the poll-storm fix deliberately stopped at
12052
+ * that boundary. The gap is named, with what closing it would cost, in the
12053
+ * out-of-scope list of
12054
+ * `.bugs/20260831-supabase-health/plan-03-dead-session-poll-loop.md`.
12055
+ *
12056
+ * ═══ THE `handle` GUARD IS WHY presence.ts CALLS THIS TWICE. ═══ Refusing
12057
+ * while the server is down is right, since there is nothing to serve and no
12058
+ * reason to hold a client. It also means the first `heartbeat`'s own
12059
+ * `setToolsClient` is a no-op, because that heartbeat runs BEFORE
12060
+ * `startToolsServer` sets `handle`. `startPresence` therefore calls this again
12061
+ * the moment the server is up, with the client that is live by then rather
12062
+ * than the one it started the server with. Without that second call a rebuild
12063
+ * during startup would leave `toolsClient` on the original client forever. */
12322
12064
  export function setToolsClient(client) {
12323
12065
  if (handle)
12324
12066
  toolsClient = client;
@@ -12727,6 +12469,15 @@ export async function heartbeatOpenSessions() {
12727
12469
  * healed, while a per-statement refusal is recorded by either. That is the
12728
12470
  * whole reason this lives here rather than in the handler.
12729
12471
  *
12472
+ * ═══ AND IT REALLY IS THE REBUILT ONE, WHICH IT WAS NOT UNTIL THE ORDERING WAS
12473
+ * FIXED. ═══ `startToolsServer` sets `toolsClient = deps.client`, and
12474
+ * `startPresence` starts the server AFTER its first heartbeat, so a rebuild in
12475
+ * that first heartbeat used to be lost twice over: `setToolsClient` refused it
12476
+ * (no `handle` yet) and the later `startToolsServer` then overwrote the field
12477
+ * with the pre-rebuild client anyway. `startPresence` now calls
12478
+ * `setToolsClient` once more the moment the server is up, so the sentence above
12479
+ * is true from the first tick rather than only after a second rebuild.
12480
+ *
12730
12481
  * BEST-EFFORT AND NEVER THROWS, following `writeWorkerStep` and the session
12731
12482
  * helpers above. This is bookkeeping about a failure; it must not turn one
12732
12483
  * refused line into a failed tool call, because the tool's own error is the
@@ -118,6 +118,7 @@
118
118
  import { createClient } from '@supabase/supabase-js';
119
119
  import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
120
120
  import { readSession } from '../config.js';
121
+ import { readableWriteError } from '../firewall.js';
121
122
  /**
122
123
  * How a v3 client holds its session, for the two lifetimes v3 has.
123
124
  *
@@ -202,7 +203,7 @@ export async function signedInClient(living = false) {
202
203
  export async function returned(query, verb, subject) {
203
204
  const { data, error } = await query;
204
205
  if (error)
205
- throw new Error(`could not ${verb} ${subject}: ${error.message}`);
206
+ throw new Error(`could not ${verb} ${subject}: ${readableWriteError(error.message)}`);
206
207
  if (!data) {
207
208
  throw new Error(`could not ${verb} ${subject}: neither data nor an error came back`);
208
209
  }