@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/README.md +1 -1
- package/dist/attachments.d.ts +1 -1
- package/dist/attachments.js +11 -4
- package/dist/builtin.d.ts +12 -0
- package/dist/builtin.js +22 -1
- package/dist/chat.d.ts +28 -2
- package/dist/chat.js +143 -25
- package/dist/config.d.ts +11 -1
- package/dist/config.js +34 -3
- package/dist/dispatch.d.ts +10 -2
- package/dist/dispatch.js +118 -20
- package/dist/floor.d.ts +80 -0
- package/dist/floor.js +652 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +397 -44
- package/dist/mode.d.ts +12 -4
- package/dist/mode.js +32 -10
- package/dist/projects.d.ts +106 -0
- package/dist/projects.js +212 -0
- package/dist/protected-shell.d.ts +88 -0
- package/dist/protected-shell.js +406 -0
- package/dist/protected-writes.d.ts +29 -0
- package/dist/protected-writes.js +54 -0
- package/dist/readonly-shell.d.ts +58 -0
- package/dist/readonly-shell.js +590 -0
- package/dist/replay.d.ts +53 -0
- package/dist/replay.js +158 -0
- package/dist/resume-tail.js +4 -1
- package/dist/resume.js +3 -3
- package/dist/retries.d.ts +9 -0
- package/dist/retries.js +16 -0
- package/dist/session-cards.d.ts +33 -4
- package/dist/session-cards.js +44 -3
- package/dist/session-migration.d.ts +104 -0
- package/dist/session-migration.js +319 -0
- package/dist/shell-words.d.ts +134 -0
- package/dist/shell-words.js +628 -0
- package/dist/skill-invoke.d.ts +58 -0
- package/dist/skill-invoke.js +139 -0
- package/dist/state.d.ts +67 -1
- package/dist/state.js +98 -2
- package/dist/trust-ui.js +29 -5
- package/package.json +15 -15
package/dist/mode.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Modes — the
|
|
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
|
|
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
|
|
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
|
-
|
|
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: "
|
|
29
|
-
"accept-edits": "
|
|
30
|
-
plan: "reads run;
|
|
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
|
|
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;
|
package/dist/projects.js
ADDED
|
@@ -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;
|