@ctrl-spc/cs 0.7.2 → 0.7.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/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
  }
@@ -4756,7 +4717,13 @@ export async function listCredentialsHandler(client) {
4756
4717
  * returns `{secret, username}`; that maps to screen 8's shapes —
4757
4718
  * api_key → `{kind, secret}`, login → `{kind, username, password}`.
4758
4719
  */
4759
- export async function getCredentialHandler(client, args,
4720
+ export async function getCredentialHandler(client,
4721
+ // `id` is the web's copy-for-agent handle, and it is the ONLY thing that can
4722
+ // resolve the ambiguity the name path can only report: names are unique per
4723
+ // (org, creator), so two credentials the caller can read may share one, and
4724
+ // by name this tool could previously do nothing but tell the user to go
4725
+ // rename something. A pasted id names the row the user actually clicked.
4726
+ args,
4760
4727
  // 18d Slice 3. OPTIONAL and trailing, so the existing two-argument call sites
4761
4728
  // (and their tests) are untouched. Absent means "not a run" — see
4762
4729
  // `rememberSecret`.
@@ -4765,26 +4732,38 @@ runTodoId = null) {
4765
4732
  // whatever casing the agent happened to type: the match below is
4766
4733
  // case-insensitive, so "talenttrack read token" must still redact as
4767
4734
  // "[redacted: TalentTrack read token]".
4735
+ const wantedId = typeof args.id === 'string' ? args.id.trim() : '';
4736
+ if (!wantedId && !args.name) {
4737
+ return errorResult('get_credential requires name — as listed by list_credentials.');
4738
+ }
4768
4739
  let match;
4769
4740
  try {
4770
4741
  const rows = (await must(client
4771
4742
  .from('credentials')
4772
4743
  .select('id, name, kind, org_id, created_by')
4773
4744
  .order('created_at', { ascending: true }))) ?? [];
4774
- const wanted = args.name.toLowerCase();
4775
- // Uniqueness is per (org, creator): the caller can see two credentials
4776
- // with the same lowercased name across two of their orgs, or within one
4777
- // org (their own plus one shared to them by another creator). Never guess
4778
- // which secret was meant. org_id/created_by are read only to word this
4779
- // error; they never appear in tool output.
4780
- const matches = rows.filter((row) => row.name.toLowerCase() === wanted);
4781
- if (matches.length > 1) {
4782
- const sameOrg = matches.every((row) => row.org_id === matches[0].org_id);
4783
- return errorResult(sameOrg
4784
- ? `credential name "${args.name}" is ambiguous — you can access more than one credential with this name in the same organization (e.g. your own and one shared with you); delete or re-create yours under a different name, or ask an org owner to revoke a shared copy`
4785
- : `credential name "${args.name}" is ambiguous you have access to credentials with this name in more than one of your organizations; delete or re-create one under a different name`);
4786
- }
4787
- match = matches[0];
4745
+ if (wantedId) {
4746
+ // An id names one row, so there is no ambiguity branch to reach. RLS
4747
+ // already filtered the rows, so a credential the caller cannot read is
4748
+ // simply absent and falls through to the uniform not-found below.
4749
+ match = rows.find((row) => row.id === wantedId);
4750
+ }
4751
+ else {
4752
+ const wanted = args.name.toLowerCase();
4753
+ // Uniqueness is per (org, creator): the caller can see two credentials
4754
+ // with the same lowercased name across two of their orgs, or within one
4755
+ // org (their own plus one shared to them by another creator). Never guess
4756
+ // which secret was meant. org_id/created_by are read only to word this
4757
+ // error; they never appear in tool output.
4758
+ const matches = rows.filter((row) => row.name.toLowerCase() === wanted);
4759
+ if (matches.length > 1) {
4760
+ const sameOrg = matches.every((row) => row.org_id === matches[0].org_id);
4761
+ return errorResult(sameOrg
4762
+ ? `credential name "${args.name}" is ambiguous — you can access more than one credential with this name in the same organization (e.g. your own and one shared with you); delete or re-create yours under a different name, or ask an org owner to revoke a shared copy`
4763
+ : `credential name "${args.name}" is ambiguous — you have access to credentials with this name in more than one of your organizations; delete or re-create one under a different name`);
4764
+ }
4765
+ match = matches[0];
4766
+ }
4788
4767
  }
4789
4768
  catch (err) {
4790
4769
  return errorResult(`get_credential failed: ${err.message}`);
@@ -4978,9 +4957,51 @@ export async function listSkillsHandler(client, args) {
4978
4957
  * survives a compaction — an agent that has lost `get_skill`'s output still has
4979
4958
  * the skill's name in the conversation and can still reach its files.
4980
4959
  */
4981
- async function resolveSkill(client, rawName, rawOrgId, tool) {
4960
+ /**
4961
+ * Resolve a skill by its row id — the handle the web's copy-for-agent button
4962
+ * pastes. An id names ONE row, so neither of the name path's two ambiguity
4963
+ * errors can arise and `org_id` has nothing left to disambiguate: the id
4964
+ * already decided which org's skill this is, and the bundle it belongs to says
4965
+ * which org that was. RLS does the access check, so a row the caller cannot see
4966
+ * is simply not found.
4967
+ */
4968
+ async function resolveSkillById(client, id, tool) {
4969
+ const rows = (await must(client
4970
+ .from('skills')
4971
+ .select('id, bundle_id, name, description, relative_path, status, status_reason')
4972
+ .eq('id', id)
4973
+ .is('deleted_at', null))) ?? [];
4974
+ const row = rows[0];
4975
+ if (!row) {
4976
+ return {
4977
+ ok: false,
4978
+ error: errorResult(`${tool}: no skill with id ${id}. It may have been deleted, or belong to an organization you ` +
4979
+ 'cannot see. Call list_skills to see the skills that exist.'),
4980
+ };
4981
+ }
4982
+ // The bundle is what carries the org, and only a LIVE bundle counts: a skill
4983
+ // row can outlive the archiving of the bundle that brought it in.
4984
+ const bundles = await liveSkillBundles(client, null);
4985
+ const orgId = bundles.find((bundle) => bundle.id === row.bundle_id)?.org_id;
4986
+ if (!orgId) {
4987
+ return {
4988
+ ok: false,
4989
+ error: errorResult(`${tool}: skill "${row.name}" belongs to a skill pack that is no longer active. ` +
4990
+ 'Re-import the pack in the web app.'),
4991
+ };
4992
+ }
4993
+ return { ok: true, value: { row, orgId } };
4994
+ }
4995
+ async function resolveSkill(client, rawName, rawOrgId, tool,
4996
+ // The web's copy-for-agent handle. A name is still the addressing an agent
4997
+ // reaches for unaided (it survives a compaction; see this section's header),
4998
+ // so `id` is the ALTERNATIVE, not the replacement: it exists because the
4999
+ // Skills page copies `/ctrl-spc skill <id>` and a pasted row must resolve to
5000
+ // the row the user clicked, never to a same-named skill in another org.
5001
+ rawId = undefined) {
5002
+ const wantedId = typeof rawId === 'string' ? rawId.trim() : '';
4982
5003
  const wanted = typeof rawName === 'string' ? rawName.trim() : '';
4983
- if (!wanted) {
5004
+ if (!wantedId && !wanted) {
4984
5005
  return {
4985
5006
  ok: false,
4986
5007
  error: errorResult(`${tool} requires name — the name of the skill to read, as listed by list_skills.`),
@@ -4989,6 +5010,8 @@ async function resolveSkill(client, rawName, rawOrgId, tool) {
4989
5010
  const filter = skillOrgFilter(rawOrgId, tool);
4990
5011
  if (!filter.ok)
4991
5012
  return filter;
5013
+ if (wantedId)
5014
+ return resolveSkillById(client, wantedId, tool);
4992
5015
  const bundles = await liveSkillBundles(client, filter.value);
4993
5016
  const orgIdByBundle = new Map(bundles.map((bundle) => [bundle.id, bundle.org_id]));
4994
5017
  const rows = orgIdByBundle.size
@@ -5167,7 +5190,7 @@ async function listSkillBundleFiles(client, orgId, bundleId, relativePath) {
5167
5190
  }
5168
5191
  export async function getSkillHandler(client, args) {
5169
5192
  try {
5170
- const resolved = await resolveSkill(client, args?.name, args?.org_id, 'get_skill');
5193
+ const resolved = await resolveSkill(client, args?.name, args?.org_id, 'get_skill', args?.id);
5171
5194
  if (!resolved.ok)
5172
5195
  return resolved.error;
5173
5196
  const { row: match, orgId } = resolved.value;
@@ -5276,7 +5299,7 @@ export async function readSkillFileHandler(client, args) {
5276
5299
  if (!wantedPath) {
5277
5300
  return errorResult('read_skill_file requires path — one of the paths get_skill listed in bundle_files.');
5278
5301
  }
5279
- const resolved = await resolveSkill(client, args?.name, args?.org_id, 'read_skill_file');
5302
+ const resolved = await resolveSkill(client, args?.name, args?.org_id, 'read_skill_file', args?.id);
5280
5303
  if (!resolved.ok)
5281
5304
  return resolved.error;
5282
5305
  const { row: match, orgId } = resolved.value;
@@ -5883,6 +5906,58 @@ async function unnarrowedEmptyNote(client, projectId) {
5883
5906
  }
5884
5907
  return NO_PROJECT_DOCUMENTS_NOTE;
5885
5908
  }
5909
+ export async function getDocumentHandler(client, args) {
5910
+ const id = typeof args.id === 'string' ? args.id.trim() : '';
5911
+ if (!id) {
5912
+ return errorResult('get_document requires id — the id of the document to read, as the web app’s “Copy for agent” ' +
5913
+ 'button pastes it.');
5914
+ }
5915
+ // Shape-checked once, here at the boundary, exactly as resolveContextProject
5916
+ // does with task_id: without it a mistyped id comes back as a Postgres
5917
+ // "invalid input syntax for type uuid", which reads like a bug in the tool.
5918
+ if (!UUID_RE.test(id)) {
5919
+ return errorResult(`get_document: not a valid document id: "${id}".`);
5920
+ }
5921
+ try {
5922
+ // Context documents first, then instructions. RLS scopes both reads, so a
5923
+ // row the caller may not see is simply absent and falls through to the
5924
+ // not-found below.
5925
+ const documents = await must(client
5926
+ .from('project_documents')
5927
+ .select('title, content, codebase_id, type')
5928
+ .eq('id', id));
5929
+ const document = (documents ?? [])[0];
5930
+ if (document)
5931
+ return textResult(shapeDocument(document));
5932
+ const instructions = await must(client.from('agent_instructions').select('title, content, codebase_id').eq('id', id));
5933
+ const instruction = (instructions ?? [])[0];
5934
+ if (instruction) {
5935
+ return textResult({
5936
+ ...shapeDocument(instruction),
5937
+ // Said plainly, so an agent that fetched one does not conclude these are
5938
+ // reference material it may choose to consult: it already has them.
5939
+ note: 'This is an agent instruction. Every instruction on this project is already delivered in ' +
5940
+ 'your prompt — reading one here does not make it optional.',
5941
+ });
5942
+ }
5943
+ return errorResult(`get_document: no document with id ${id}. It may have been deleted, or belong to a project you ` +
5944
+ 'cannot see. Call get_project_context to read the documents on the project you are working.');
5945
+ }
5946
+ catch (err) {
5947
+ return errorResult(`get_document failed: ${errorMessage(err)}`);
5948
+ }
5949
+ }
5950
+ /** The one shape both tables report in, so a caller never has to branch on
5951
+ * which table answered. `scope` is what `codebase_id` MEANS — null is the
5952
+ * project's own document, a value is that codebase's. */
5953
+ function shapeDocument(row) {
5954
+ return {
5955
+ title: row.title,
5956
+ ...(row.type ? { type: row.type } : {}),
5957
+ scope: row.codebase_id === null ? 'project' : 'codebase',
5958
+ content: row.content,
5959
+ };
5960
+ }
5886
5961
  // ---------------------------------------------------------------------------
5887
5962
  // Project context (feature 13a, Phase 4) — `propose_project_context`.
5888
5963
  //
@@ -5957,19 +6032,6 @@ const MAX_PROPOSALS = 50;
5957
6032
  * make a legal document un-acceptable — so the BATCH is what is bounded, which
5958
6033
  * is the thing that actually protects the write. */
5959
6034
  const MAX_PROPOSAL_BATCH_CHARS = 400_000;
5960
- /** Absolute in any form a repo path can arrive in: POSIX (`/etc`), UNC and
5961
- * root-relative Windows (`\\server\share`, `\Users\Lane\repo`), Windows
5962
- * drive-letter (`C:\repo`, `C:/repo`) and drive-RELATIVE (`C:AGENTS.md`, which
5963
- * resolves against that drive's current directory and still discloses a local
5964
- * layout). One leading separator of EITHER kind, or any drive letter, is
5965
- * enough — a single leading backslash is a Windows absolute path just as `/`
5966
- * is a POSIX one.
5967
- *
5968
- * `cliv2_work_reservations_path_relative`
5969
- * (20260722160000_cliv2_coordination.sql) is the same rule for the same
5970
- * reason, and it has the narrower form with both of those holes. That is a
5971
- * pre-existing bug on that table, not a licence to repeat it here. */
5972
- const ABSOLUTE_PATH_RE = /^(?:[\\/]|[A-Za-z]:)/;
5973
6035
  /** A `..` segment under either separator. A relative path that climbs out of
5974
6036
  * the scanned repo names a file the scan had no business reading, and it is
5975
6037
  * refused for the same privacy reason an absolute path is. */
@@ -7584,220 +7646,6 @@ const STEP_SOURCE_KINDS = ['workflow', 'plan'];
7584
7646
  * sets the opening value; `update_step` writes every transition after it —
7585
7647
  * including 'done', which is the only one `cliv2_steps_up_next` advances on. */
7586
7648
  const STEP_STATUSES = ['pending', 'in_progress', 'blocked', 'interrupted', 'done'];
7587
- /** Decode the HTML entities a browser would decode, so the guard scans what
7588
- * the USER will eventually read rather than what the agent happened to type.
7589
- * Without this, `&#47;Users&#47;lane&#47;x` sails past every scan and then
7590
- * `interactiveArtifactHtml()` — which parses with DOMParser — paints the
7591
- * real absolute path into hosted browser JS. The guard and the renderer must
7592
- * agree about what a string SAYS; entities are exactly where they diverge.
7593
- * Numeric (decimal and hex) plus the small set of named entities that can
7594
- * spell a path separator or a drive colon. */
7595
- const NAMED_ENTITIES = {
7596
- sol: '/',
7597
- bsol: '\\',
7598
- colon: ':',
7599
- period: '.',
7600
- lowbar: '_',
7601
- quot: '"',
7602
- apos: "'",
7603
- amp: '&',
7604
- lt: '<',
7605
- gt: '>',
7606
- };
7607
- /* The trailing `;` is OPTIONAL for numeric entities, because browsers accept
7608
- it that way: `&#47Users` renders as `/Users`. A guard that required the
7609
- semicolon allowed exactly that string through while the renderer painted a
7610
- real path — found by differentially testing this function against jsdom's
7611
- DOMParser, which is the same parser interactiveArtifactHtml() uses. Named
7612
- entities keep the required `;` (that is what browsers do outside a short
7613
- legacy list, and dropping it would eat `&sole` in ordinary prose). */
7614
- function decodeEntities(text) {
7615
- return text.replace(/&(?:(#[Xx][0-9A-Fa-f]+|#\d+);?|([A-Za-z][A-Za-z0-9]*);)/g, (whole, numericBody, namedBody) => {
7616
- const body = numericBody ?? namedBody ?? '';
7617
- if (body.startsWith('#')) {
7618
- const code = body[1] === 'x' || body[1] === 'X'
7619
- ? Number.parseInt(body.slice(2), 16)
7620
- : Number.parseInt(body.slice(1), 10);
7621
- return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
7622
- }
7623
- // Named entities are case-SENSITIVE in HTML: `&Colon;` is U+2237 (∷), not
7624
- // a colon, so lower-casing here refused text the browser never renders as
7625
- // a path. Exact match only.
7626
- const named = NAMED_ENTITIES[body];
7627
- return named ?? whole;
7628
- });
7629
- }
7630
- /**
7631
- * The first absolute local path inside `text`, or null.
7632
- *
7633
- * THE DESIGN, and why it is not the previous one. This helper used to split
7634
- * the text into tokens on a separator list and test each token. Three review
7635
- * rounds each answered a leak by adding characters to that list (`(,=;`, then
7636
- * `<>"'`), and the third round proved the approach is wrong rather than
7637
- * incomplete: a quote *inside* a path (`/'Users/lane/x`, `/Users/lane/Lane's
7638
- * Docs/x`) SPLIT the path into fragments, none of which starts with a root
7639
- * marker — so widening the separator list to catch markup simultaneously
7640
- * opened a hole for paths containing that markup, and the refusal message for
7641
- * a legitimate apostrophe path named a truncated path that did not exist.
7642
- * Separators cannot both be inside and outside the thing being matched.
7643
- *
7644
- * So: do not tokenise. SCAN for the path shape itself, anchored at a root
7645
- * marker, and let the match end where a character that cannot appear in a
7646
- * path appears. A path is a match, not a token. The exclusions below are the
7647
- * genuine false positives, tested against the same corpus as before:
7648
- *
7649
- * - a URL's authority (`https://host/path`) is not a local path — a match
7650
- * immediately preceded by `//` of a non-`file:` scheme is skipped, while
7651
- * `file:///Users/…` IS reported (its body is a real local path);
7652
- * - a bare drive label `A:` with no path body is a list marker;
7653
- * - a single-segment `/word` (`/dashboard`, `/api/`) is a URL path — local
7654
- * paths in prose always carry a second separator.
7655
- *
7656
- * Entities are decoded first (see `decodeEntities`), because the renderer
7657
- * decodes them too.
7658
- */
7659
- /* The scan finds the path SHAPE anywhere in the text and decides what it is
7660
- from WHAT MATCHED, not from what precedes it.
7661
- *
7662
- * A previous round anchored this with a lookbehind excluding "characters a
7663
- * path cannot follow". That was the separator-list mistake one layer down: the
7664
- * exclusion set is itself a character list, and every character in it became a
7665
- * hiding place. `~/Users/lane/x`, `x./Users/lane/x` and a path embedded in a
7666
- * URL all leaked, and `~/…` is an entirely ordinary thing for an agent to
7667
- * write. A rule that says "not after these characters" can always be defeated
7668
- * by writing one of them first.
7669
- *
7670
- * So there is no lookbehind. Instead the two genuine false positives are
7671
- * excluded by structure, below:
7672
- * - a repo-relative path (`web/src/App.tsx`) never starts at a root marker,
7673
- * so requiring the match to BEGIN with `/`, `\\` or `C:` already excludes
7674
- * it — `/src/App.tsx` inside it is only reachable mid-token, which the
7675
- * single-segment and second-separator rules then handle;
7676
- * - a URL's authority is recognised by its own scheme, checked explicitly.
7677
- *
7678
- * `'` and `"` are NOT body terminators — a quote inside a path is exactly the
7679
- * case that made the separator-splitting design leak. */
7680
- const ABSOLUTE_PATH_SCAN = /(?:[A-Za-z]:[\\/'"]|\\\\|\/)[^\s<>`(){}\[\],;|&*?\n\r\t]*/g;
7681
- /* The POSIX roots that actually hold a user's files. A leading `/` alone does
7682
- NOT make a machine path — `/api/items`, `/img/logo.png` and `/dashboard` are
7683
- site paths, and an agent's panel is full of them (href, src, srcset, CSS
7684
- url()). Refusing those would make the house style unusable and agents would
7685
- route around the guard, which is worse than a narrower rule. These roots are
7686
- the ones whose contents are private to the machine. */
7687
- const POSIX_MACHINE_ROOT = /^\/(?:Users|home|root|var|etc|opt|srv|private|tmp|mnt|media|Volumes|Applications|Library|System|usr\/local|dev)(?:\/|$)/i;
7688
- /** A machine path buried inside a longer match — the segment of a remote URL
7689
- * that spells one (`https://host/x/Users/lane/secret.txt`). The scan's body
7690
- * is greedy, so such a path never gets a match of its own; it has to be dug
7691
- * out of the match that swallowed it. Rendered in front of a viewer, it
7692
- * discloses exactly what bare text would. */
7693
- function buriedMachinePath(candidate) {
7694
- // A Windows path can be buried too — `https://host/xC:\Users\Lane\x` — and
7695
- // it does not begin at a `/`, so scanning only slash positions missed it.
7696
- // Shape, not just a colon-slash: a drive letter, a separator, then at least
7697
- // one real segment. `a:/b` in a URL query is not a Windows path; the `\` or
7698
- // a `Users`-style segment is what makes it one. Requiring a BACKSLASH
7699
- // separator keeps this narrow — a forward-slash drive path (`C:/x`) inside a
7700
- // URL is indistinguishable from an ordinary URL fragment, and the POSIX pass
7701
- // below already covers the roots that matter.
7702
- const drive = /[A-Za-z]:\\[^\s<>"'`]+/.exec(candidate);
7703
- if (drive)
7704
- return candidate.slice(drive.index);
7705
- for (let at = candidate.indexOf('/'); at !== -1; at = candidate.indexOf('/', at + 1)) {
7706
- const tail = candidate.slice(at);
7707
- if (POSIX_MACHINE_ROOT.test(tail))
7708
- return tail;
7709
- }
7710
- return null;
7711
- }
7712
- function absolutePathToken(text) {
7713
- const decoded = decodeEntities(text);
7714
- // `file:///Users/lane/x` is a local path wearing a URL. Handle it up front
7715
- // and by NAME, rather than leaving the generic scan to reason about where a
7716
- // scheme ends — that reasoning is what produced two of this function's bugs.
7717
- const fileUrl = /\bfile:\/\/(\/\S*)/i.exec(decoded);
7718
- if (fileUrl && POSIX_MACHINE_ROOT.test(fileUrl[1]))
7719
- return fileUrl[1];
7720
- ABSOLUTE_PATH_SCAN.lastIndex = 0;
7721
- let match;
7722
- while ((match = ABSOLUTE_PATH_SCAN.exec(decoded)) !== null) {
7723
- // Trailing wrapping punctuation belongs to the prose, not the path:
7724
- // `("/Users/lane/x.md")` and `see /Users/lane/x.md.` both end early.
7725
- const candidate = match[0].replace(/["')\].,:;]+$/, '');
7726
- if (!candidate || !ABSOLUTE_PATH_RE.test(candidate))
7727
- continue;
7728
- const before = decoded.slice(0, match.index);
7729
- // A URL: skip its authority and its ordinary path — `https://host/a/b`
7730
- // matches at `//host/a/b` and again at `/b`, and neither is a local path.
7731
- // The scheme identifies it, so look for the scheme rather than testing the
7732
- // character immediately before. (`file:` never reaches here; it is handled
7733
- // by name above.)
7734
- //
7735
- // EXCEPT when the URL's path is itself rooted at a machine root. A remote
7736
- // URL spelling `…/x/Users/lane/private/secret.txt` puts that path in front
7737
- // of a viewer just as plainly as bare text does, and the contract is about
7738
- // the string reaching hosted browser JS, not about how it got there. A
7739
- // false alarm costs the agent one retry; a leak is permanent, so this errs
7740
- // toward refusing.
7741
- if (/([A-Za-z][A-Za-z0-9+.-]*):\/\/\S*$/.test(before) && !POSIX_MACHINE_ROOT.test(candidate)) {
7742
- const buried = buriedMachinePath(candidate);
7743
- if (buried)
7744
- return buried;
7745
- continue;
7746
- }
7747
- // `https://host/x` also matches AT the scheme's own `s://…`, which reads
7748
- // as a one-letter drive. A real drive letter is followed by a separator
7749
- // and then a path segment — never by `//`. Before discarding it, check
7750
- // whether a machine root is buried INSIDE: the scan's body is greedy, so
7751
- // `s://example.com/x/Users/lane/x` is one match and the `/Users/…` inside
7752
- // it never gets a match of its own. A remote URL spelling a machine path
7753
- // discloses it to a viewer just as plainly as bare text does.
7754
- if (/^[A-Za-z]:\/\//.test(candidate)) {
7755
- const buried = buriedMachinePath(candidate);
7756
- if (buried)
7757
- return buried;
7758
- continue;
7759
- }
7760
- // A CONTINUATION of a relative path: `web/src/App.tsx` matches at
7761
- // `/src/App.tsx`, which is not a local path — the token it belongs to
7762
- // began with a bare word. Walk back to the start of the whole token and
7763
- // ask what IT begins with. This is deliberately not "is the preceding
7764
- // character in a set": `~/Users/lane/x` and `x./Users/lane/x` both have a
7765
- // path-ish character before the slash, and both DO carry a real absolute
7766
- // path, so a character test leaks them. Reading the token's own first
7767
- // character answers correctly in every one of those cases.
7768
- // The token runs back to whitespace OR a markup boundary (`<>"'=`) — a
7769
- // quote or a tag bracket ends the token even though it is not whitespace,
7770
- // which is what lets `class="mono">/Users/…` be seen as a path rather than
7771
- // as the tail of the token `class`.
7772
- // Only a token that is itself a plausible RELATIVE PATH suppresses the
7773
- // match — `web/src` in `web/src/App.tsx`. A bare word (`x.`), a number
7774
- // (`1`), or anything not shaped like a path segment does not, because
7775
- // `x./Users/lane/x` and `1/Users/lane/x` still carry a real absolute path.
7776
- const tokenStart = /([^\s<>"'=]+)$/.exec(before)?.[1] ?? '';
7777
- if (/^[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9._-]+)*\/?$/.test(tokenStart)
7778
- && tokenStart.includes('/')
7779
- && !POSIX_MACHINE_ROOT.test(candidate))
7780
- continue;
7781
- if (/^[A-Za-z]:$/.test(candidate))
7782
- continue;
7783
- // A POSIX candidate is only a MACHINE path if it starts at a real root.
7784
- // `/img/x.png`, `/api/items`, `/dashboard` and `2026/07/31` are site paths
7785
- // and dates — they are not local paths, they are the ordinary content of
7786
- // an agent's panel (CSS urls, hrefs, srcset), and refusing them makes the
7787
- // house style unusable. The roots below are the ones that actually carry a
7788
- // user's files; a path under any of them is refused, everything else is
7789
- // left alone. Windows (`C:\…`) and UNC (`\\host\share`) are unambiguous by
7790
- // construction and are not filtered here.
7791
- // Strip a quote sitting immediately after the root before classifying:
7792
- // `/'Users/lane/x` is the separator-design leak, and the root test must
7793
- // see `/Users/…` to recognise it.
7794
- const rooted = candidate.replace(/^([\\/]+)["']/, '$1');
7795
- if (rooted.startsWith('/') && !POSIX_MACHINE_ROOT.test(rooted))
7796
- continue;
7797
- return candidate;
7798
- }
7799
- return null;
7800
- }
7801
7649
  /** Refuse the WHOLE call when any field carries an absolute local path —
7802
7650
  * naming the field and the offending token, so the agent can fix exactly
7803
7651
  * that. Refusal happens before any write; nothing is rewritten. */
@@ -9802,6 +9650,7 @@ export const TOOL_NAMES = [
9802
9650
  'get_skill',
9803
9651
  'read_skill_file',
9804
9652
  'get_project_context',
9653
+ 'get_document',
9805
9654
  'propose_project_context',
9806
9655
  'list_product_ideas',
9807
9656
  'create_product_idea',
@@ -9900,7 +9749,9 @@ const SERVER_INSTRUCTIONS = `You are connected to CTRL+SPC, where this user's wo
9900
9749
 
9901
9750
  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.
9902
9751
 
9903
- The terminal conversation disappears when the process does. What you put in CTRL+SPC is what survives.`;
9752
+ The terminal conversation disappears when the process does. What you put in CTRL+SPC is what survives.
9753
+
9754
+ Everything you write into CTRL+SPC is saved the same way, whichever tool you use. ${FIREWALL_WRITING_RULE}`;
9904
9755
  export function buildToolsServer(client, userId, machineId, connectionId,
9905
9756
  /** 18c SLICE 1 — the request (`cliv2_loose_todos.id`) this connection is
9906
9757
  * working, from the connection's own URL rather than a tool argument the
@@ -9964,7 +9815,8 @@ runTodoIdSource = null) {
9964
9815
  + 'client (get_client_context lists them under `projects` — one project resolves the choice itself, several means '
9965
9816
  + "you choose and say why). Ground the idea in the client's own words in a CITED ARTIFACT on it "
9966
9817
  + '(create_artifact) — the description is the human\'s plain-text surface and carries no citation lines. '
9967
- + CITATION_LINE_TEACHING,
9818
+ + CITATION_LINE_TEACHING + ' '
9819
+ + FIREWALL_WRITING_RULE,
9968
9820
  inputSchema: {
9969
9821
  project_id: z.string(),
9970
9822
  title: z
@@ -10018,7 +9870,8 @@ runTodoIdSource = null) {
10018
9870
  '`grounding` naming exactly what you read. The manifest is stored with the artifact and its ' +
10019
9871
  'absence is visible to every reader: a user_story written WITHOUT reading available client ' +
10020
9872
  'context must say so in its opening line. ' +
10021
- CITATION_LINE_TEACHING,
9873
+ CITATION_LINE_TEACHING + ' ' +
9874
+ FIREWALL_WRITING_RULE,
10022
9875
  inputSchema: {
10023
9876
  task_id: z.string(),
10024
9877
  type: z.enum(['analysis', 'plan', 'spec', 'diagram', 'mock', 'wireframe', 'user_story']),
@@ -10111,7 +9964,8 @@ runTodoIdSource = null) {
10111
9964
  + '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. '
10112
9965
  + 'Update a task you own — its status (backlog / in_progress / done), name, description, or due_date. ' +
10113
9966
  'Pass expected_revision from the most recent get_task for optimistic concurrency; on a conflict, ' +
10114
- 'call get_task again to re-read and retry. The change appears in the web board.',
9967
+ 'call get_task again to re-read and retry. The change appears in the web board. ' +
9968
+ FIREWALL_WRITING_RULE,
10115
9969
  inputSchema: {
10116
9970
  id: z.string().describe('Task id'),
10117
9971
  expected_revision: z
@@ -10133,7 +9987,8 @@ runTodoIdSource = null) {
10133
9987
  server.registerTool('update_artifact', {
10134
9988
  description: 'Edit an artifact you can access — its title, content, type, or format. Pass expected_revision from the ' +
10135
9989
  'most recent get_task (optimistic concurrency); on a conflict, call get_task again and retry. ' +
10136
- 'The change appears in the web UI.',
9990
+ 'The change appears in the web UI. ' +
9991
+ FIREWALL_WRITING_RULE,
10137
9992
  inputSchema: {
10138
9993
  id: z.string(),
10139
9994
  expected_revision: z
@@ -10205,7 +10060,8 @@ runTodoIdSource = null) {
10205
10060
  return setTaskRoleSlugsHandler(client, args);
10206
10061
  });
10207
10062
  server.registerTool('add_comment', {
10208
- description: 'Leave a note on a task. The comment appears in the web UI, authored by you.',
10063
+ description: 'Leave a note on a task. The comment appears in the web UI, authored by you. ' +
10064
+ FIREWALL_WRITING_RULE,
10209
10065
  inputSchema: {
10210
10066
  task_id: z.string().describe('Task id'),
10211
10067
  body: z.string().min(1),
@@ -10225,7 +10081,8 @@ runTodoIdSource = null) {
10225
10081
  'READ BEFORE YOU WRITE: list what exists (list_tasks) before adding structure, and cut items ' +
10226
10082
  'along real seams — separately buildable, separately testable — not one item per noun in the ' +
10227
10083
  'ask. Add NO duration estimates to names or descriptions unless the user asked: agents ' +
10228
- 'overestimate toward human timelines, and sequencing, not duration, is the value.',
10084
+ 'overestimate toward human timelines, and sequencing, not duration, is the value. ' +
10085
+ FIREWALL_WRITING_RULE,
10229
10086
  inputSchema: {
10230
10087
  project_id: z.string().describe('Project id the task belongs to'),
10231
10088
  name: z.string().min(1),
@@ -10997,7 +10854,8 @@ runTodoIdSource = null) {
10997
10854
  'present_wireframes ' +
10998
10855
  'again for iterations of the same design. Requires an open session (begin_work). If diagrams are created ' +
10999
10856
  'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
11000
- '— follow them instead of calling this tool again.',
10857
+ '— follow them instead of calling this tool again. ' +
10858
+ FIREWALL_WRITING_RULE,
11001
10859
  inputSchema: {
11002
10860
  title: z.string().min(1).describe('Short title for the presentation (shown on the work item)'),
11003
10861
  kind: z.enum(['ui', 'architecture']).describe("'ui' for wireframes, 'architecture' for code/flow diagrams"),
@@ -11047,7 +10905,8 @@ runTodoIdSource = null) {
11047
10905
  'present_mocks ' +
11048
10906
  'again for iterations of the same design. Requires an open session (begin_work). If mocks are created ' +
11049
10907
  'but the review request fails, the error includes the created artifacts and exact recovery instructions ' +
11050
- '— follow them instead of calling this tool again.',
10908
+ '— follow them instead of calling this tool again. ' +
10909
+ FIREWALL_WRITING_RULE,
11051
10910
  // This schema DECLARES every field the handler reads and CONSTRAINS none
11052
10911
  // of what the handler validates — the two halves of the Phase 1 defect.
11053
10912
  // DECLARE, because the SDK parses args with z.object(inputSchema) and
@@ -11132,7 +10991,8 @@ runTodoIdSource = null) {
11132
10991
  });
11133
10992
  server.registerTool('record_context_exploration', {
11134
10993
  description: "Record what you explored for this work item as its context document (the canonical 'Work item context' " +
11135
- 'for the item). Repeated calls update the same document. Appears in the web UI.',
10994
+ 'for the item). Repeated calls update the same document. Appears in the web UI. ' +
10995
+ FIREWALL_WRITING_RULE,
11136
10996
  inputSchema: {
11137
10997
  content: z.string().min(1),
11138
10998
  },
@@ -11216,7 +11076,16 @@ runTodoIdSource = null) {
11216
11076
  'comments, artifacts, documents and questions, and name the credential instead. Never refuse the ' +
11217
11077
  'work to avoid touching the value.',
11218
11078
  inputSchema: {
11219
- name: z.string().min(1).describe('Credential name, as listed by list_credentials'),
11079
+ name: z
11080
+ .string()
11081
+ .min(1)
11082
+ .optional()
11083
+ .describe('Credential name, as listed by list_credentials'),
11084
+ id: z
11085
+ .string()
11086
+ .optional()
11087
+ .describe('Credential id, as pasted by the web app\'s "Copy for agent" button. Use it instead of ' +
11088
+ 'name; it names exactly one credential, where a name can be ambiguous.'),
11220
11089
  },
11221
11090
  }, async (args) => {
11222
11091
  touchSession(connectionId);
@@ -11250,7 +11119,12 @@ runTodoIdSource = null) {
11250
11119
  'about to do. If two organizations hold a skill of the same name this refuses rather than ' +
11251
11120
  'guessing: pass org_id to say which you mean. Read-only.',
11252
11121
  inputSchema: {
11253
- name: z.string().min(1).describe('Skill name, as listed by list_skills'),
11122
+ name: z.string().min(1).optional().describe('Skill name, as listed by list_skills'),
11123
+ id: z
11124
+ .string()
11125
+ .optional()
11126
+ .describe('Skill id, as pasted by the web app\'s "Copy for agent" button. Use it instead of name; ' +
11127
+ 'it names exactly one skill, so org_id is never needed with it.'),
11254
11128
  org_id: z
11255
11129
  .string()
11256
11130
  .optional()
@@ -11266,7 +11140,11 @@ runTodoIdSource = null) {
11266
11140
  'those paths here. WHEN A SKILL POINTS AT A FILE BESIDE IT, FETCH IT AND FOLLOW IT — never ask ' +
11267
11141
  'the user to supply a file the organization already stored. Text only, up to 512 KB. Read-only.',
11268
11142
  inputSchema: {
11269
- name: z.string().min(1).describe('Skill name, as listed by list_skills'),
11143
+ name: z.string().min(1).optional().describe('Skill name, as listed by list_skills'),
11144
+ id: z
11145
+ .string()
11146
+ .optional()
11147
+ .describe('Skill id. Use it instead of name; it names exactly one skill.'),
11270
11148
  path: z
11271
11149
  .string()
11272
11150
  .min(1)
@@ -11313,6 +11191,22 @@ runTodoIdSource = null) {
11313
11191
  touchSession(connectionId);
11314
11192
  return getProjectContextHandler(client, openSessions.get(connectionId) ?? null, args);
11315
11193
  });
11194
+ server.registerTool('get_document', {
11195
+ description: 'Read ONE context document or agent instruction by its id — the whole document, not an excerpt. ' +
11196
+ 'Use it when someone hands you an id (the web app’s “Copy for agent” button pastes one), or when ' +
11197
+ 'get_project_context named a document you need in full. It resolves either kind, so you do not ' +
11198
+ 'need to know which one the id belongs to. To read a project’s context as a whole instead, call ' +
11199
+ 'get_project_context. Read-only.',
11200
+ inputSchema: {
11201
+ id: z
11202
+ .string()
11203
+ .min(1)
11204
+ .describe('Document id, as the web app’s “Copy for agent” button pastes it'),
11205
+ },
11206
+ }, async (args) => {
11207
+ touchSession(connectionId);
11208
+ return getDocumentHandler(client, args);
11209
+ });
11316
11210
  server.registerTool('propose_project_context', {
11317
11211
  // The description TEACHES: when to reach for this (after reading a repo),
11318
11212
  // what a good proposal is (content it actually read, one line of why),
@@ -11327,7 +11221,8 @@ runTodoIdSource = null) {
11327
11221
  'writes proposals the user accepts or rejects in the web app, under Project settings → Project ' +
11328
11222
  'context. Say so when you report back. Re-running replaces this codebase\'s pending proposals; send ' +
11329
11223
  'an empty `proposals` array to report that the repo holds no standing context, which clears them. ' +
11330
- "Uses the open session's task when `task_id` is omitted.",
11224
+ "Uses the open session's task when `task_id` is omitted. " +
11225
+ FIREWALL_WRITING_RULE,
11331
11226
  inputSchema: {
11332
11227
  task_id: z
11333
11228
  .string()
@@ -12168,8 +12063,26 @@ function sessionForAttribution(connectionId) {
12168
12063
  * token the presence loop rebuilt after a wedge/refresh flows into the session
12169
12064
  * heartbeat too (FIX 2 — the CLI-v1 stale-token root cause). Only takes effect
12170
12065
  * while the tools server is running; the per-connection tool handlers keep their
12171
- * own captured client, which is acceptable the session heartbeat is what must
12172
- * stay healthy for the presence chip. */
12066
+ * own captured client, which is acceptable here, because the session heartbeat
12067
+ * is what must stay healthy for the presence chip.
12068
+ *
12069
+ * THAT REASONING IS LOCAL TO THIS FILE AND DOES NOT CARRY ACROSS. A `panel3`
12070
+ * tool handler serves project writes rather than a heartbeat, so a captured
12071
+ * client there is a refused write against real work, not a chip that goes grey.
12072
+ * `panel3/tools.ts` takes its client by value and fans it out to every handler
12073
+ * for that connection's life, and the poll-storm fix deliberately stopped at
12074
+ * that boundary. The gap is named, with what closing it would cost, in the
12075
+ * out-of-scope list of
12076
+ * `.bugs/20260831-supabase-health/plan-03-dead-session-poll-loop.md`.
12077
+ *
12078
+ * ═══ THE `handle` GUARD IS WHY presence.ts CALLS THIS TWICE. ═══ Refusing
12079
+ * while the server is down is right, since there is nothing to serve and no
12080
+ * reason to hold a client. It also means the first `heartbeat`'s own
12081
+ * `setToolsClient` is a no-op, because that heartbeat runs BEFORE
12082
+ * `startToolsServer` sets `handle`. `startPresence` therefore calls this again
12083
+ * the moment the server is up, with the client that is live by then rather
12084
+ * than the one it started the server with. Without that second call a rebuild
12085
+ * during startup would leave `toolsClient` on the original client forever. */
12173
12086
  export function setToolsClient(client) {
12174
12087
  if (handle)
12175
12088
  toolsClient = client;
@@ -12578,6 +12491,15 @@ export async function heartbeatOpenSessions() {
12578
12491
  * healed, while a per-statement refusal is recorded by either. That is the
12579
12492
  * whole reason this lives here rather than in the handler.
12580
12493
  *
12494
+ * ═══ AND IT REALLY IS THE REBUILT ONE, WHICH IT WAS NOT UNTIL THE ORDERING WAS
12495
+ * FIXED. ═══ `startToolsServer` sets `toolsClient = deps.client`, and
12496
+ * `startPresence` starts the server AFTER its first heartbeat, so a rebuild in
12497
+ * that first heartbeat used to be lost twice over: `setToolsClient` refused it
12498
+ * (no `handle` yet) and the later `startToolsServer` then overwrote the field
12499
+ * with the pre-rebuild client anyway. `startPresence` now calls
12500
+ * `setToolsClient` once more the moment the server is up, so the sentence above
12501
+ * is true from the first tick rather than only after a second rebuild.
12502
+ *
12581
12503
  * BEST-EFFORT AND NEVER THROWS, following `writeWorkerStep` and the session
12582
12504
  * helpers above. This is bookkeeping about a failure; it must not turn one
12583
12505
  * refused line into a failed tool call, because the tool's own error is the