@vincemakes/kiso-code 0.39.2 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mode.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Modes — the five built-in approval tiers, built ON the E1 policy chain
2
+ * Modes — the six built-in approval tiers, built ON the E1 policy chain
3
3
  * (the kernel is untouched). Each tier is an in-process "mode:<name>"
4
4
  * extension whose decide() is live — it only speaks when it is the
5
5
  * CURRENT tier (otherwise abstain = no opinion, ADR-0042), so /mode
@@ -10,8 +10,16 @@
10
10
  * monotonicity — bypass cannot override an extension deny).
11
11
  */
12
12
  import type { KisoExtension } from "@vincemakes/kiso-runtime";
13
- export type Mode = "manual" | "default" | "accept-edits" | "plan" | "bypass";
13
+ export type Mode = "manual" | "default" | "accept-edits" | "plan" | "bypass" | "dontAsk";
14
+ /** Every tier kiso ACCEPTS — from a config, KISO_MODE, --mode, or
15
+ * `/mode <name>`. */
14
16
  export declare const MODES: readonly Mode[];
17
+ /** The tiers kiso OFFERS a person (launch-weekend plan §2): the picker,
18
+ * shift+tab, the printed list, the help. `manual` left the offer — a
19
+ * saved allow outranks its ask, so "asks for every tool" was never
20
+ * quite what it did — and stays accepted, so no config that names it
21
+ * breaks. `dontAsk` joined: the unattended tier. */
22
+ export declare const OFFERED_MODES: readonly Mode[];
15
23
  /** DC-36 — one line per tier, for the picker, TRANSCRIBED FROM decide()
16
24
  * below rather than written fresh. A description that drifts from the
17
25
  * behaviour is worse than none: this is the row a human reads before
@@ -32,7 +40,7 @@ export declare function setMode(m: Mode): void;
32
40
  * config layer's mode then applies — merge round B; the --mode flag is
33
41
  * applied by main before the first makeAgent and wins over everything). */
34
42
  export declare function modeFromEnv(): Mode | undefined;
35
- /** The five built-in mode tiers as chain extensions — named "mode:<tier>"
43
+ /** The six built-in mode tiers as chain extensions — named "mode:<tier>"
36
44
  * so the runtime's decidedBy records exactly that (the runtime derives
37
45
  * approvalPolicies from extensions[].approvals, tagging each with the
38
46
  * extension name). The CURRENT tier is first: an all-allow chain records
@@ -41,7 +49,7 @@ export declare function modeFromEnv(): Mode | undefined;
41
49
  * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
42
50
  * user extension's deny wins over any mode tier, bypass included (the
43
51
  * monotonicity e2e pins it). */
44
- export declare function modeExtensions(): readonly KisoExtension[];
52
+ export declare function modeExtensions(workspaceRoot?: () => string): readonly KisoExtension[];
45
53
  /** The plan tier's system prompt addition — injected at startup when the
46
54
  * initial mode is plan (the session prompt is fixed at creation; runtime
47
55
  * switches are guided by the deny reason). */
package/dist/mode.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Modes — the five built-in approval tiers, built ON the E1 policy chain
2
+ * Modes — the six built-in approval tiers, built ON the E1 policy chain
3
3
  * (the kernel is untouched). Each tier is an in-process "mode:<name>"
4
4
  * extension whose decide() is live — it only speaks when it is the
5
5
  * CURRENT tier (otherwise abstain = no opinion, ADR-0042), so /mode
@@ -9,7 +9,16 @@
9
9
  * tiers; a user deny always wins (the chain's deny>allow>ask
10
10
  * monotonicity — bypass cannot override an extension deny).
11
11
  */
12
- export const MODES = ["manual", "default", "accept-edits", "plan", "bypass"];
12
+ import { isProtectedWrite } from "./protected-writes.js";
13
+ /** Every tier kiso ACCEPTS — from a config, KISO_MODE, --mode, or
14
+ * `/mode <name>`. */
15
+ export const MODES = ["manual", "default", "accept-edits", "plan", "bypass", "dontAsk"];
16
+ /** The tiers kiso OFFERS a person (launch-weekend plan §2): the picker,
17
+ * shift+tab, the printed list, the help. `manual` left the offer — a
18
+ * saved allow outranks its ask, so "asks for every tool" was never
19
+ * quite what it did — and stays accepted, so no config that names it
20
+ * breaks. `dontAsk` joined: the unattended tier. */
21
+ export const OFFERED_MODES = ["default", "accept-edits", "plan", "dontAsk", "bypass"];
13
22
  /** DC-36 — one line per tier, for the picker, TRANSCRIBED FROM decide()
14
23
  * below rather than written fresh. A description that drifts from the
15
24
  * behaviour is worse than none: this is the row a human reads before
@@ -23,12 +32,17 @@ export const MODES = ["manual", "default", "accept-edits", "plan", "bypass"];
23
32
  * no new question. The composition (deny > allow > ask) is the ruling and
24
33
  * is unchanged; the copy was the thing that was wrong. `plan` needs no
25
34
  * qualification because it DENIES, and a deny is what nothing overrides. */
35
+ // 0.40.0: every OFFERED note fits the picker at 80 columns — 60 columns:
36
+ // 80, less the 19 of the label column, less the last column, which is
37
+ // never written — so the qualification at the END of a note is never the
38
+ // part that is cut (the PTY picker gate asserts them whole).
26
39
  export const MODE_NOTE = {
27
40
  manual: "asks for every tool — a saved allow still allows",
28
- default: "reads run; writes, edits and shell ask — a saved allow still allows",
29
- "accept-edits": "reads, writes and edits run; shell asks — a saved allow still allows",
30
- plan: "reads run; everything else is denied — read-only, and a deny wins",
41
+ default: "read-only runs; the rest asks — a saved allow still allows",
42
+ "accept-edits": "read-only, edits run; rest asks — a saved allow still allows",
43
+ plan: "reads run; all else is denied — read-only, and a deny wins",
31
44
  bypass: "everything runs, nothing asks — a user deny still wins",
45
+ dontAsk: "asks nothing: an ask is denied — a saved allow still allows",
32
46
  };
33
47
  /** The read-only tool set (plan): reading is allowed, everything else
34
48
  * denied with the guiding reason. */
@@ -48,13 +62,19 @@ export function modeFromEnv() {
48
62
  return MODES.find((x) => x === raw);
49
63
  }
50
64
  /** The per-tier verdict for a tool call — only when this tier is current. */
51
- function tierVerdict(tier, call) {
65
+ function tierVerdict(tier, call, workspaceRoot) {
52
66
  if (tier !== current)
53
67
  return { action: "abstain" }; // not our tier — no opinion
54
68
  switch (tier) {
55
69
  case "manual":
56
70
  return { action: "ask" }; // every tool asks
71
+ // dontAsk decides exactly as default does. What makes it dontAsk is
72
+ // what happens to the chain's final ASK: the CLI denies it at the
73
+ // ask endpoint instead of asking (chat.ts). Not a tier deny — that
74
+ // would outrank every allow, and a saved allow and the read-only
75
+ // shell allow must still allow.
57
76
  case "default":
77
+ case "dontAsk":
58
78
  if (READ_TOOLS.has(call.name))
59
79
  return { action: "allow" };
60
80
  if (call.name === "write_file" || call.name === "edit_file" || call.name === "shell")
@@ -68,8 +88,10 @@ function tierVerdict(tier, call) {
68
88
  case "accept-edits":
69
89
  if (READ_TOOLS.has(call.name))
70
90
  return { action: "allow" };
91
+ // 0.40.0: a write into .git/ or .kiso/ asks even here — both hold
92
+ // configuration that runs (protected-writes.ts)
71
93
  if (call.name === "write_file" || call.name === "edit_file")
72
- return { action: "allow" };
94
+ return isProtectedWrite(call, workspaceRoot()) ? { action: "ask" } : { action: "allow" };
73
95
  if (call.name === "shell")
74
96
  return { action: "ask" };
75
97
  return { action: "abstain" }; // see "default"
@@ -81,7 +103,7 @@ function tierVerdict(tier, call) {
81
103
  return { action: "allow" }; // everything — a REAL allow, never an abstain
82
104
  }
83
105
  }
84
- /** The five built-in mode tiers as chain extensions — named "mode:<tier>"
106
+ /** The six built-in mode tiers as chain extensions — named "mode:<tier>"
85
107
  * so the runtime's decidedBy records exactly that (the runtime derives
86
108
  * approvalPolicies from extensions[].approvals, tagging each with the
87
109
  * extension name). The CURRENT tier is first: an all-allow chain records
@@ -90,12 +112,12 @@ function tierVerdict(tier, call) {
90
112
  * deny>allow>ask over the SPEAKING verdicts (abstain = no opinion), so a
91
113
  * user extension's deny wins over any mode tier, bypass included (the
92
114
  * monotonicity e2e pins it). */
93
- export function modeExtensions() {
115
+ export function modeExtensions(workspaceRoot = () => process.cwd()) {
94
116
  return [...MODES.filter((m) => m === current), ...MODES.filter((m) => m !== current)].map((m) => ({
95
117
  name: `mode:${m}`,
96
118
  approvals: [
97
119
  {
98
- decide: async (payload) => tierVerdict(m, { name: payload.name }),
120
+ decide: async (payload) => tierVerdict(m, { name: payload.name, input: payload.input ?? {} }, workspaceRoot),
99
121
  },
100
122
  ],
101
123
  }));
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 0.40.0 (the owner's dogfood) — one session folder per project.
3
+ *
4
+ * Every session used to live in one `KISO_HOME/sessions`, and a session of
5
+ * one project could be resumed from another. Its file tools, rooted at the
6
+ * cwd, then pointed at the wrong tree. A session now lives in the folder of
7
+ * the project it runs in, `KISO_HOME/projects/<enc>/`, and another project's
8
+ * session is not in this folder by construction.
9
+ *
10
+ * <enc> the workspace realpath, every non-alphanumeric turned
11
+ * into `-` (a readable name, not an identity)
12
+ * workspace.json the realpath the folder belongs to — the identity. A
13
+ * folder naming another realpath is never adopted; the
14
+ * newcomer's name takes a `-<sha8>` suffix instead
15
+ * _unknown/ legacy sessions whose project could not be read
16
+ *
17
+ * A session's PLACEMENT is derived, never written: in a project folder, a
18
+ * session whose profile records a workspace is `recorded`, one without is
19
+ * `inferred` (only the one-time migration puts it there); in `_unknown`, it
20
+ * is `unknown`. The evidence behind an inference is in the migration's
21
+ * manifest.
22
+ *
23
+ * `KISO_SESSIONS_DIR` pins the folder outright (tests, bench runners, a
24
+ * delegated child writing beside its parent), and the reverse migration's
25
+ * marker restores the single legacy folder. Either one turns this layout
26
+ * off, and with it every cross-project rule below.
27
+ */
28
+ export declare const PROJECTS_DIR = "projects";
29
+ export declare const LEGACY_SESSIONS_DIR = "sessions";
30
+ export declare const UNKNOWN_PROJECT = "_unknown";
31
+ export declare const WORKSPACE_FILE = "workspace.json";
32
+ /** Written by `kiso sessions --reverse-migration`: while it exists, the
33
+ * single legacy folder is in force again, for this build and older ones. */
34
+ export declare const REVERSED_MARKER = ".migration-reversed";
35
+ /**
36
+ * A path as the DISK spells it — symlinks resolved and, on a
37
+ * case-insensitive disk, the stored case. The JS realpath keeps the case it
38
+ * was given, and `~/desktop/x` and `~/Desktop/x` must be one project, not
39
+ * two folders. A path that no longer exists is kept as written.
40
+ */
41
+ export declare function canonicalPath(p: string): string;
42
+ export declare function encodeWorkspace(workspace: string): string;
43
+ /** The realpath a folder belongs to; null when it records none. */
44
+ export declare function folderWorkspace(dir: string): string | null;
45
+ /**
46
+ * The folder of a workspace. A pure read: the name is the encoding unless
47
+ * that folder already belongs to another realpath (two paths can encode
48
+ * alike: `/a/b-c` and `/a/b/c`), and then it takes the suffix. `taken` is
49
+ * a batch's own assignments, which are not on disk yet.
50
+ */
51
+ export declare function projectDirFor(home: string, workspace: string, taken?: ReadonlyMap<string, string>): string;
52
+ export declare function unknownDir(home: string): string;
53
+ /** Make the folder and record whose it is. The write is tmp + rename, so a
54
+ * reader never sees half a `workspace.json`; an existing record is kept. */
55
+ export declare function claimProjectDir(dir: string, workspace: string | null): void;
56
+ /** Whether the per-project layout is in force for this process. */
57
+ export declare function projectLayoutActive(home: string, env?: NodeJS.ProcessEnv): boolean;
58
+ export interface SessionFolder {
59
+ readonly dir: string;
60
+ /** the realpath the folder belongs to; null for `_unknown` and the
61
+ * legacy folder, whose sessions have no project */
62
+ readonly workspace: string | null;
63
+ readonly kind: "project" | "unknown" | "legacy";
64
+ }
65
+ /** Every folder that can hold sessions: the project folders, `_unknown`,
66
+ * and the legacy folder while anything is left in it. Directory reads
67
+ * only. */
68
+ export declare function sessionFolders(home: string): SessionFolder[];
69
+ /** A non-empty log is a session (the listing's own rule). */
70
+ export declare function hasSession(dir: string, id: string): boolean;
71
+ /** The folder holding a session, other than `except`; null when none. */
72
+ export declare function locateSession(home: string, id: string, except: string): SessionFolder | null;
73
+ /**
74
+ * The files of one session, in the order they move: the trace, the sidecar,
75
+ * the lock, and the log LAST. A crash part-way leaves the log where it was,
76
+ * so the session is still listed and still moved on the next attempt; each
77
+ * file moves only when it is at the source and absent at the target, so a
78
+ * repeated move is a no-op.
79
+ */
80
+ export declare function sessionFiles(id: string): readonly string[];
81
+ export declare function moveSessionFiles(fromDir: string, toDir: string, id: string, files?: readonly string[]): number;
82
+ /**
83
+ * Whether a lock is held by a live process. The lock file carries the
84
+ * holder's `{pid, token}` (a legacy one, a bare pid); an empty file is the
85
+ * released marker. The probe is conservative on purpose — a pid that exists
86
+ * at all counts as live, a zombie included — because the only cost of a
87
+ * false "live" is a move deferred to a later start.
88
+ */
89
+ export declare function lockHeldLive(dir: string, id: string): boolean;
90
+ /** Whether a session id may open in this project (index.ts routeSession):
91
+ * its own folder; refused, with where to go; or opened where it is, in
92
+ * another folder, with the line that says so (null: nothing to say). */
93
+ export type SessionRoute = {
94
+ readonly kind: "here";
95
+ } | {
96
+ readonly kind: "refused";
97
+ readonly line: string;
98
+ } | {
99
+ readonly kind: "elsewhere";
100
+ readonly dir: string;
101
+ readonly line: string | null;
102
+ };
103
+ /** A delegated child's id names its parent:
104
+ * `sub-<parentId>-<24 hex>-<n>-<role>`, or, before CX-1 F6 gave each
105
+ * delegation its own id, `sub-<parentId>-<n>-<role>`. */
106
+ export declare function parentOfChild(id: string): string | null;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 0.40.0 (the owner's dogfood) — one session folder per project.
3
+ *
4
+ * Every session used to live in one `KISO_HOME/sessions`, and a session of
5
+ * one project could be resumed from another. Its file tools, rooted at the
6
+ * cwd, then pointed at the wrong tree. A session now lives in the folder of
7
+ * the project it runs in, `KISO_HOME/projects/<enc>/`, and another project's
8
+ * session is not in this folder by construction.
9
+ *
10
+ * <enc> the workspace realpath, every non-alphanumeric turned
11
+ * into `-` (a readable name, not an identity)
12
+ * workspace.json the realpath the folder belongs to — the identity. A
13
+ * folder naming another realpath is never adopted; the
14
+ * newcomer's name takes a `-<sha8>` suffix instead
15
+ * _unknown/ legacy sessions whose project could not be read
16
+ *
17
+ * A session's PLACEMENT is derived, never written: in a project folder, a
18
+ * session whose profile records a workspace is `recorded`, one without is
19
+ * `inferred` (only the one-time migration puts it there); in `_unknown`, it
20
+ * is `unknown`. The evidence behind an inference is in the migration's
21
+ * manifest.
22
+ *
23
+ * `KISO_SESSIONS_DIR` pins the folder outright (tests, bench runners, a
24
+ * delegated child writing beside its parent), and the reverse migration's
25
+ * marker restores the single legacy folder. Either one turns this layout
26
+ * off, and with it every cross-project rule below.
27
+ */
28
+ import { createHash } from "node:crypto";
29
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ export const PROJECTS_DIR = "projects";
32
+ export const LEGACY_SESSIONS_DIR = "sessions";
33
+ export const UNKNOWN_PROJECT = "_unknown";
34
+ export const WORKSPACE_FILE = "workspace.json";
35
+ /** Written by `kiso sessions --reverse-migration`: while it exists, the
36
+ * single legacy folder is in force again, for this build and older ones. */
37
+ export const REVERSED_MARKER = ".migration-reversed";
38
+ /** A folder name stays well under the file system's 255-byte limit. */
39
+ const NAME_MAX = 200;
40
+ /**
41
+ * A path as the DISK spells it — symlinks resolved and, on a
42
+ * case-insensitive disk, the stored case. The JS realpath keeps the case it
43
+ * was given, and `~/desktop/x` and `~/Desktop/x` must be one project, not
44
+ * two folders. A path that no longer exists is kept as written.
45
+ */
46
+ export function canonicalPath(p) {
47
+ try {
48
+ return realpathSync.native(p);
49
+ }
50
+ catch {
51
+ return p;
52
+ }
53
+ }
54
+ const sha8 = (s) => createHash("sha256").update(s).digest("hex").slice(0, 8);
55
+ export function encodeWorkspace(workspace) {
56
+ const enc = workspace.replace(/[^A-Za-z0-9]/g, "-");
57
+ return enc.length <= NAME_MAX ? enc : `${enc.slice(0, NAME_MAX)}-${sha8(workspace)}`;
58
+ }
59
+ /** The realpath a folder belongs to; null when it records none. */
60
+ export function folderWorkspace(dir) {
61
+ try {
62
+ const parsed = JSON.parse(readFileSync(join(dir, WORKSPACE_FILE), "utf8"));
63
+ return typeof parsed.workspace === "string" && parsed.workspace !== "" ? parsed.workspace : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ /**
70
+ * The folder of a workspace. A pure read: the name is the encoding unless
71
+ * that folder already belongs to another realpath (two paths can encode
72
+ * alike: `/a/b-c` and `/a/b/c`), and then it takes the suffix. `taken` is
73
+ * a batch's own assignments, which are not on disk yet.
74
+ */
75
+ export function projectDirFor(home, workspace, taken) {
76
+ const base = join(home, PROJECTS_DIR, encodeWorkspace(workspace));
77
+ const owner = taken?.get(base) ?? folderWorkspace(base);
78
+ return owner === null || owner === undefined || owner === workspace ? base : `${base}-${sha8(workspace)}`;
79
+ }
80
+ export function unknownDir(home) {
81
+ return join(home, PROJECTS_DIR, UNKNOWN_PROJECT);
82
+ }
83
+ /** Make the folder and record whose it is. The write is tmp + rename, so a
84
+ * reader never sees half a `workspace.json`; an existing record is kept. */
85
+ export function claimProjectDir(dir, workspace) {
86
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
87
+ if (workspace === null || folderWorkspace(dir) !== null)
88
+ return;
89
+ const tmp = join(dir, `${WORKSPACE_FILE}.${process.pid}.tmp`);
90
+ writeFileSync(tmp, `${JSON.stringify({ workspace, createdAt: new Date().toISOString() })}\n`, { mode: 0o600 });
91
+ renameSync(tmp, join(dir, WORKSPACE_FILE));
92
+ }
93
+ /** Whether the per-project layout is in force for this process. */
94
+ export function projectLayoutActive(home, env = process.env) {
95
+ const pinned = env.KISO_SESSIONS_DIR;
96
+ if (pinned !== undefined && pinned !== "")
97
+ return false;
98
+ return !existsSync(join(home, PROJECTS_DIR, REVERSED_MARKER));
99
+ }
100
+ /** Every folder that can hold sessions: the project folders, `_unknown`,
101
+ * and the legacy folder while anything is left in it. Directory reads
102
+ * only. */
103
+ export function sessionFolders(home) {
104
+ const out = [];
105
+ const root = join(home, PROJECTS_DIR);
106
+ let names = [];
107
+ try {
108
+ names = readdirSync(root).sort();
109
+ }
110
+ catch {
111
+ // no projects yet
112
+ }
113
+ for (const name of names) {
114
+ const dir = join(root, name);
115
+ try {
116
+ if (!statSync(dir).isDirectory())
117
+ continue;
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ out.push(name === UNKNOWN_PROJECT ? { dir, workspace: null, kind: "unknown" } : { dir, workspace: folderWorkspace(dir), kind: "project" });
123
+ }
124
+ const legacy = join(home, LEGACY_SESSIONS_DIR);
125
+ if (existsSync(legacy))
126
+ out.push({ dir: legacy, workspace: null, kind: "legacy" });
127
+ return out;
128
+ }
129
+ /** A non-empty log is a session (the listing's own rule). */
130
+ export function hasSession(dir, id) {
131
+ try {
132
+ return statSync(join(dir, `${id}.jsonl`)).size > 0;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ /** The folder holding a session, other than `except`; null when none. */
139
+ export function locateSession(home, id, except) {
140
+ for (const folder of sessionFolders(home)) {
141
+ if (folder.dir === except)
142
+ continue;
143
+ if (hasSession(folder.dir, id))
144
+ return folder;
145
+ }
146
+ return null;
147
+ }
148
+ /**
149
+ * The files of one session, in the order they move: the trace, the sidecar,
150
+ * the lock, and the log LAST. A crash part-way leaves the log where it was,
151
+ * so the session is still listed and still moved on the next attempt; each
152
+ * file moves only when it is at the source and absent at the target, so a
153
+ * repeated move is a no-op.
154
+ */
155
+ export function sessionFiles(id) {
156
+ return [join("traces", `${id}.jsonl`), `${id}.meta.json`, `${id}.lock`, `${id}.jsonl`];
157
+ }
158
+ export function moveSessionFiles(fromDir, toDir, id, files = sessionFiles(id)) {
159
+ let moved = 0;
160
+ for (const rel of files) {
161
+ const from = join(fromDir, rel);
162
+ const to = join(toDir, rel);
163
+ if (!existsSync(from) || existsSync(to))
164
+ continue;
165
+ mkdirSync(join(to, ".."), { recursive: true, mode: 0o700 });
166
+ renameSync(from, to);
167
+ moved += 1;
168
+ }
169
+ return moved;
170
+ }
171
+ /**
172
+ * Whether a lock is held by a live process. The lock file carries the
173
+ * holder's `{pid, token}` (a legacy one, a bare pid); an empty file is the
174
+ * released marker. The probe is conservative on purpose — a pid that exists
175
+ * at all counts as live, a zombie included — because the only cost of a
176
+ * false "live" is a move deferred to a later start.
177
+ */
178
+ export function lockHeldLive(dir, id) {
179
+ let text;
180
+ try {
181
+ text = readFileSync(join(dir, `${id}.lock`), "utf8").trim();
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ if (text === "")
187
+ return false;
188
+ let pid;
189
+ try {
190
+ const parsed = JSON.parse(text);
191
+ pid = typeof parsed === "number" ? parsed : Number(parsed.pid);
192
+ }
193
+ catch {
194
+ pid = Number(text);
195
+ }
196
+ if (!Number.isInteger(pid) || pid <= 0)
197
+ return false;
198
+ try {
199
+ process.kill(pid, 0);
200
+ return true;
201
+ }
202
+ catch (err) {
203
+ return err.code === "EPERM";
204
+ }
205
+ }
206
+ /** A delegated child's id names its parent:
207
+ * `sub-<parentId>-<24 hex>-<n>-<role>`, or, before CX-1 F6 gave each
208
+ * delegation its own id, `sub-<parentId>-<n>-<role>`. */
209
+ export function parentOfChild(id) {
210
+ const m = /^sub-(.+?)(?:-[0-9a-f]{24})?-\d+-[a-z]+$/.exec(id);
211
+ return m === null ? null : m[1];
212
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * kiso never serves its own credential store to a model — the SHELL half.
3
+ * The file tools refuse in tools-node, whose protected.ts holds the rule
4
+ * (what is protected, and how a path is matched to it).
5
+ *
6
+ * A chain member at the head, beside the floor, in EVERY mode including
7
+ * bypass: any word of any command in the line that resolves to a protected
8
+ * file DENIES. The `!` gesture runs the same check before it runs a line,
9
+ * because `!cmd` sends that line's output to the model.
10
+ *
11
+ * THIS IS A READING, NOT A GUARANTEE. It reads the line as the floor does:
12
+ * the loose reader, `cd` followed, subshells and `sh -c`/`eval` inner lines
13
+ * read, `~`, `$HOME` and `$KISO_HOME` expanded, `--opt=path` split, and the
14
+ * words the floor drops kept — a redirection's target, an assignment's
15
+ * value. A word is resolved by the KERNEL, as the command would open it:
16
+ * one `stat` of the directory it runs in joined to the word, compared by
17
+ * inode — so a symlink, a `..` through one, a case variant and a hard link
18
+ * under another name all reach the same file. A protected path that does
19
+ * not exist yet has no inode: until the file is created it is matched by
20
+ * its spelling, resolved against the directory the word runs in (so is a
21
+ * name-suffixed sibling, the writer's temp file). A glob is matched against
22
+ * the protected files themselves, and only when its literal directory
23
+ * holds one. A word it cannot resolve (a variable, a substitution, another
24
+ * user's `~name`), or a relative word read after a `cd` it could not
25
+ * follow, denies when it still contains a protected file's name. So does a
26
+ * command that names a directory holding a protected file AND that file's
27
+ * name, in two words (`find ~/.kiso -name auth.json`). A line it cannot
28
+ * read to the end — nested too deep, or past its budget of disk reads —
29
+ * denies.
30
+ *
31
+ * Its blind spots are stated, not hidden: a path assembled at run time
32
+ * (`base64 -d`, string slicing), a copy under another name made in an
33
+ * earlier call, a script that reads the file, a recursive walk over a
34
+ * directory that holds it without naming it (`grep -r x ~`). The
35
+ * guarantee is the sandbox, which stays post-launch.
36
+ */
37
+ import type { KisoExtension } from "@vincemakes/kiso-runtime";
38
+ import { type ProtectedIdentity } from "@vincemakes/kiso-tools-node";
39
+ /** A hit names the word that reached a protected file — or, `unread`,
40
+ * says why the line could not be read to the end. */
41
+ export type ProtectedVerdict = {
42
+ readonly hit: false;
43
+ } | {
44
+ readonly hit: true;
45
+ readonly why: string;
46
+ readonly unread: boolean;
47
+ };
48
+ export interface ProtectedShellEnv {
49
+ /** what `~` and `$HOME` stand for */
50
+ readonly home: string;
51
+ /** what `$KISO_HOME` stands for */
52
+ readonly kisoHome: string;
53
+ }
54
+ export declare const NESTED_TOO_DEEP = "the line is nested deeper than the check reads";
55
+ export declare const TOO_LONG = "the line names more paths than the check reads";
56
+ export declare const TOO_MANY_CDS = "the line changes directory more times than the check follows";
57
+ /** The pure check: does any word of the line name a protected file? */
58
+ export declare function protectedShellCheck(line: string, workspaceRoot: string, id: ProtectedIdentity, env: ProtectedShellEnv): ProtectedVerdict;
59
+ /** The refusal the MODEL reads. Permanent, as the file tools say it, so
60
+ * it does not retry under another spelling. */
61
+ export declare function protectedShellReason(v: {
62
+ readonly why: string;
63
+ readonly unread: boolean;
64
+ }): string;
65
+ /** The refusal the PERSON reads, for their own `!` line: what to do
66
+ * instead. */
67
+ export declare function protectedBangReason(v: {
68
+ readonly why: string;
69
+ readonly unread: boolean;
70
+ }): string;
71
+ export type ProtectedShellCheck = typeof protectedShellCheck;
72
+ /** The whole question for one line, as both callers ask it. A line the
73
+ * check throws on is refused: a line it could not read is not a line it
74
+ * may let through. */
75
+ export declare function protectedShellVerdict(line: string, workspaceRoot: string, files: readonly string[], env: ProtectedShellEnv, check?: ProtectedShellCheck): {
76
+ readonly refused: false;
77
+ } | {
78
+ readonly refused: true;
79
+ readonly why: string;
80
+ readonly unread: boolean;
81
+ };
82
+ /** The chain member: deny-or-abstain, every mode. */
83
+ export declare function protectedShellExtension(opts: {
84
+ readonly files: () => readonly string[];
85
+ readonly workspaceRoot: () => string;
86
+ readonly env: () => ProtectedShellEnv;
87
+ readonly check?: ProtectedShellCheck;
88
+ }): KisoExtension;