projectkeeper 0.0.0-stage → 1.0.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/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read boundary and shell write guard as pi sees them (Spec §3.1, §8.1; CKC-03 AC-23;
|
|
3
|
+
* CKC-26 AC-8; DECISIONS E69). The
|
|
4
|
+
* Keeper keeps every built-in tool — read, write, edit, shell, grep, find, ls — with nothing
|
|
5
|
+
* removed. The boundary governs *where a read can reach*, not *which tools exist*:
|
|
6
|
+
*
|
|
7
|
+
* - read / grep / find / ls, and edit (which returns file content in its diff), are gated by an
|
|
8
|
+
* inline extension's `tool_call` handler: a path outside the allowed roots is refused before the
|
|
9
|
+
* tool runs, and the refusal is recorded as a step in the Keeper view (like AC-16). read / edit /
|
|
10
|
+
* grep / ls are ALSO re-provided through pi's own definition with boundary-enforcing Operations
|
|
11
|
+
* (`guardedFileTool`): a second layer that checks the path pi has already resolved, independent of
|
|
12
|
+
* the gate's argument parsing and of the resolver copy in paths.ts. (find's fd backend does not
|
|
13
|
+
* hand its Operations the resolved path, so find stays gated only.)
|
|
14
|
+
* - shell (bash / powershell) is parsed before it runs, then the reachable Git repositories or
|
|
15
|
+
* ordinary directories are journaled and restored on a detected write (command.ts and
|
|
16
|
+
* shell-write-guard.ts): on a live project (a home that watches its projects) only what the
|
|
17
|
+
* command names as its writes, the rest left and noted on the step; on a controlled trial every
|
|
18
|
+
* change; Git's own state never (BQ). Its spawn hook
|
|
19
|
+
* also strips credential variables (PA-10) and sets a private job scratch directory.
|
|
20
|
+
* - the file tools retain their existing policy; this module's write guard applies to shell calls.
|
|
21
|
+
*
|
|
22
|
+
* Allowed roots: the project's own scope items, this project's ProjectKeeper assets, the toolchain
|
|
23
|
+
* its config points at, the global skills / extensions / prompt templates pi loaded (but not their
|
|
24
|
+
* login or credential files), and — file by file — the session logs that belong to this project.
|
|
25
|
+
* A root that does not come from the project itself (a toolchain location, a skill's folder) is not
|
|
26
|
+
* used when it is too broad — a filesystem root, the home directory or anything containing it, the
|
|
27
|
+
* ProjectKeeper home or anything containing it or inside it, or anything containing the project —
|
|
28
|
+
* and is returned as refused instead, so it can be shown and corrected (paths.ts `broadRootReason`).
|
|
29
|
+
*/
|
|
30
|
+
import { homedir, tmpdir } from 'node:os';
|
|
31
|
+
import { basename, dirname, join, sep } from 'node:path';
|
|
32
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
33
|
+
import { constants, lstatSync, mkdirSync } from 'node:fs';
|
|
34
|
+
import { access as fsAccess, readFile as fsReadFile, readdir as fsReaddir, stat as fsStat, writeFile as fsWriteFile } from 'node:fs/promises';
|
|
35
|
+
import {
|
|
36
|
+
createBashToolDefinition, createPowerShellToolDefinition,
|
|
37
|
+
createReadToolDefinition, createEditToolDefinition, createGrepToolDefinition, createLsToolDefinition,
|
|
38
|
+
detectSupportedImageMimeTypeFromFile,
|
|
39
|
+
type DefaultResourceLoader, type InlineExtension, type SettingsManager, type ToolDefinition,
|
|
40
|
+
} from '@earendil-works/pi-coding-agent';
|
|
41
|
+
import type { Project } from '../../model/types.ts';
|
|
42
|
+
import type { ProjectStore } from '../../store/project-store.ts';
|
|
43
|
+
import { toolchainRoots } from '../../scope/toolchain.ts';
|
|
44
|
+
import { broadRootReason, canonicalKey, makeBoundary, type AllowedRoot, type BreadthContext, type Boundary } from './paths.ts';
|
|
45
|
+
import { planShellCommand } from './command.ts';
|
|
46
|
+
import { LEFT_NOTE, ShellProjectSnapshot, shellProjectRoots, withShellWriteLock } from './shell-write-guard.ts';
|
|
47
|
+
|
|
48
|
+
/** Environment-variable names whose value is a secret and must not reach a tool subprocess (PA-10). */
|
|
49
|
+
export function isCredentialEnvName(name: string): boolean {
|
|
50
|
+
const upper = name.toUpperCase();
|
|
51
|
+
if (upper === 'PWD' || upper === 'PATH' || upper === 'OLDPWD') return false; // working-directory vars, not secrets
|
|
52
|
+
return /(?:^|_)(?:KEY|APIKEY|TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|CREDENTIAL|CREDENTIALS|AUTH)(?:$|_)/.test(upper)
|
|
53
|
+
|| /(?:ACCESS|PRIVATE|SECRET)[_-]?KEY/.test(upper)
|
|
54
|
+
|| upper.endsWith('_KEY') || upper.endsWith('_TOKEN') || upper.endsWith('_SECRET');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Git takes settings from the environment as one group: `GIT_CONFIG_COUNT`, then a name and a value for each setting
|
|
59
|
+
* (`GIT_CONFIG_KEY_0`, `GIT_CONFIG_VALUE_0`, …). The group is read whole or not at all — with the count left and a
|
|
60
|
+
* name gone, every git command stops with "missing config key" — and the secret, when there is one, is in a value,
|
|
61
|
+
* whose variable name says nothing. So the group is filtered by what each setting is, not by the variables' names.
|
|
62
|
+
*/
|
|
63
|
+
const GIT_CONFIG_GROUP = /^GIT_CONFIG_(?:COUNT|(?:KEY|VALUE)_\d+)$/;
|
|
64
|
+
/**
|
|
65
|
+
* The sections of git's configuration that pass to a tool subprocess: who commits, which directories git trusts, and
|
|
66
|
+
* how a repository on this machine is read and shown. A secret sits where git reaches a remote (an `http.….extraheader`,
|
|
67
|
+
* a `credential.*` helper, a `url.….insteadOf` with a token in it), and none of these sections does that. A setting
|
|
68
|
+
* passes because its section is listed, never because it does not look like a secret.
|
|
69
|
+
*/
|
|
70
|
+
const LOCAL_GIT_SECTIONS = new Set(['user', 'author', 'committer', 'safe', 'core', 'init', 'i18n', 'color', 'diff', 'log', 'status', 'advice', 'gc', 'feature', 'index', 'pack']);
|
|
71
|
+
/** The `core` settings that are commands for reaching, or signing in to, a remote. */
|
|
72
|
+
const REMOTE_CORE_SETTINGS = new Set(['askpass', 'sshcommand', 'gitproxy']);
|
|
73
|
+
|
|
74
|
+
/** Whether a git setting named `key` (`section.name` or `section.subsection.name`) passes to a tool subprocess. */
|
|
75
|
+
export function isLocalGitSetting(key: string): boolean {
|
|
76
|
+
const first = key.indexOf('.');
|
|
77
|
+
if (first <= 0) return false;
|
|
78
|
+
const section = key.slice(0, first).toLowerCase();
|
|
79
|
+
const name = key.slice(key.lastIndexOf('.') + 1).toLowerCase();
|
|
80
|
+
return LOCAL_GIT_SECTIONS.has(section) && !(section === 'core' && REMOTE_CORE_SETTINGS.has(name));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Remove credential-named variables from an environment map (used by the shell spawn hook). Git's settings group is
|
|
85
|
+
* rebuilt from the settings that pass, numbered from 0 again in their order, so git reads the rest as before.
|
|
86
|
+
* `GIT_CONFIG_PARAMETERS`, where git hands the `-c` settings of one command to its children, is not passed on.
|
|
87
|
+
*/
|
|
88
|
+
export function stripCredentialEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
89
|
+
const out: NodeJS.ProcessEnv = {};
|
|
90
|
+
const group = new Map<string, string>();
|
|
91
|
+
for (const [k, v] of Object.entries(env)) {
|
|
92
|
+
const upper = k.toUpperCase();
|
|
93
|
+
if (GIT_CONFIG_GROUP.test(upper)) { if (v !== undefined) group.set(upper, v); continue; }
|
|
94
|
+
if (upper === 'GIT_CONFIG_PARAMETERS') continue;
|
|
95
|
+
if (!isCredentialEnvName(k)) out[k] = v;
|
|
96
|
+
}
|
|
97
|
+
// Git reads the settings 0 … count-1 and stops at the first one that lacks its name or its value; so does this.
|
|
98
|
+
const count = /^\d+$/.test(group.get('GIT_CONFIG_COUNT') ?? '') ? Number(group.get('GIT_CONFIG_COUNT')) : 0;
|
|
99
|
+
let kept = 0;
|
|
100
|
+
for (let i = 0; i < count; i += 1) {
|
|
101
|
+
const key = group.get(`GIT_CONFIG_KEY_${i}`);
|
|
102
|
+
const value = group.get(`GIT_CONFIG_VALUE_${i}`);
|
|
103
|
+
if (key === undefined || value === undefined) break;
|
|
104
|
+
if (!isLocalGitSetting(key)) continue;
|
|
105
|
+
out[`GIT_CONFIG_KEY_${kept}`] = key;
|
|
106
|
+
out[`GIT_CONFIG_VALUE_${kept}`] = value;
|
|
107
|
+
kept += 1;
|
|
108
|
+
}
|
|
109
|
+
if (kept > 0) out.GIT_CONFIG_COUNT = String(kept);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Variables that are no secret themselves but lead to a login stored on this machine, so they do not reach the shell:
|
|
115
|
+
* - the programs git, ssh and sudo call to be given a password (`GIT_ASKPASS`, `SSH_ASKPASS` with `SSH_ASKPASS_REQUIRE`,
|
|
116
|
+
* `SUDO_ASKPASS`). An editor's terminal sets the first two to a helper of its own that answers from the editor's
|
|
117
|
+
* sign-in, over the channel the `VSCODE_GIT_*` variables name (VS Code and the editors built on it, on every system);
|
|
118
|
+
* - git's trace switches (`GIT_TRACE*`, `GIT_CURL_VERBOSE`) and the credential manager's (`GCM_TRACE*`), which print
|
|
119
|
+
* what is sent to a remote: with `GIT_TRACE_REDACT=0`, the authorization header as it is.
|
|
120
|
+
*/
|
|
121
|
+
const LOGIN_ROUTE_ENV = /^(?:GIT_ASKPASS|SSH_ASKPASS|SSH_ASKPASS_REQUIRE|SUDO_ASKPASS|VSCODE_GIT_\w*|GIT_TRACE\w*|GIT_CURL_VERBOSE|GCM_TRACE\w*)$/;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The environment a shell command runs in: without the credentials and without the routes to a stored login, and with
|
|
125
|
+
* three git settings of its own after the ones that were given, where they hold whatever any configuration says:
|
|
126
|
+
* - `credential.helper` empty, which makes git forget every helper configured before it; with nobody to ask
|
|
127
|
+
* (`GIT_TERMINAL_PROMPT=0`, no askpass program) git in the shell has no login to use or to print, and says so at
|
|
128
|
+
* once. command.ts refuses `git credential` when it is typed; this holds for git reached any other way;
|
|
129
|
+
* - `core.longpaths`, the last. The program's own git calls are given it on the command line (util/git.ts
|
|
130
|
+
* `LONG_PATHS`); git typed into the shell needs it as much — in a repository at a deep path, or with files deep
|
|
131
|
+
* inside it, `git status` and `git log` stop with "Filename too long" otherwise.
|
|
132
|
+
* And git is kept from writing the index to refresh it, which changes the repository's state and takes its lock from
|
|
133
|
+
* whoever is working in it: `GIT_OPTIONAL_LOCKS=0` for `git status`, as the program's own git calls have it, and the
|
|
134
|
+
* setting `diff.autoRefreshIndex=false` for `git diff`, which does not ask about optional locks.
|
|
135
|
+
*/
|
|
136
|
+
export function shellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
137
|
+
const out: NodeJS.ProcessEnv = {};
|
|
138
|
+
for (const [k, v] of Object.entries(stripCredentialEnv(env))) if (!LOGIN_ROUTE_ENV.test(k.toUpperCase()) && !['GIT_TERMINAL_PROMPT', 'GIT_OPTIONAL_LOCKS'].includes(k.toUpperCase())) out[k] = v;
|
|
139
|
+
const count = Number(out.GIT_CONFIG_COUNT ?? 0);
|
|
140
|
+
return {
|
|
141
|
+
...out, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', GIT_CONFIG_COUNT: String(count + 3),
|
|
142
|
+
[`GIT_CONFIG_KEY_${count}`]: 'credential.helper', [`GIT_CONFIG_VALUE_${count}`]: '',
|
|
143
|
+
[`GIT_CONFIG_KEY_${count + 1}`]: 'diff.autoRefreshIndex', [`GIT_CONFIG_VALUE_${count + 1}`]: 'false',
|
|
144
|
+
[`GIT_CONFIG_KEY_${count + 2}`]: 'core.longpaths', [`GIT_CONFIG_VALUE_${count + 2}`]: 'true',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A credential store must never be read even when it sits inside an allowed root (CKC-03 AC-3, §3.1). */
|
|
149
|
+
function credentialFileDeny(agentDirKey: string): (canonical: string) => string | null {
|
|
150
|
+
const PRIVATE_KEY = /^(\.netrc|_netrc|id_(rsa|dsa|ecdsa|ed25519))$|\.(pem|ppk|pfx|p12)$/i;
|
|
151
|
+
const CREDENTIAL_SEGMENTS = new Set(['.ssh', '.aws', '.gnupg']);
|
|
152
|
+
return (canonical) => {
|
|
153
|
+
const base = basename(canonical);
|
|
154
|
+
// pi's own login/model store under the agent dir stays out of bounds, though its skills and extensions are read.
|
|
155
|
+
if ((base === 'auth.json' || base === 'models.json') && canonical.startsWith(agentDirKey)) {
|
|
156
|
+
return `Refused: ${base} is a login/credential store and is never read (only that a credential is present is noted).`;
|
|
157
|
+
}
|
|
158
|
+
if (PRIVATE_KEY.test(base)) return `Refused: ${base} is a credential store and is never read.`;
|
|
159
|
+
if (canonical.split(/[\\/]/).some((s) => CREDENTIAL_SEGMENTS.has(s))) return 'Refused: a credential store (.ssh / .aws / .gnupg) is never read.';
|
|
160
|
+
return null;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ReadBoundaryDeps {
|
|
165
|
+
readonly project: Project;
|
|
166
|
+
readonly store: ProjectStore;
|
|
167
|
+
readonly agentDir: string;
|
|
168
|
+
readonly settingsManager: SettingsManager;
|
|
169
|
+
/** The resource loader, resolved lazily: it is created with this boundary's extension, so it does
|
|
170
|
+
* not exist yet when the boundary is built, and its resources are read only on the first tool call. */
|
|
171
|
+
readonly resolveLoader: () => DefaultResourceLoader | null;
|
|
172
|
+
/** Called once per refused read/command, so a job can record how many boundary refusals it hit. */
|
|
173
|
+
readonly onDeny?: () => void;
|
|
174
|
+
/** The user's home directory (default: the real one); tests pass a temp directory. */
|
|
175
|
+
readonly home?: string;
|
|
176
|
+
/** The ProjectKeeper home in use (default: the directory above `store.dir`'s `projects/`). */
|
|
177
|
+
readonly projectKeeperHome?: string;
|
|
178
|
+
/** The active job; a live pi session can serve a later job, which gets a different scratch directory. */
|
|
179
|
+
readonly jobId?: string;
|
|
180
|
+
/**
|
|
181
|
+
* Whether this home watches its projects (settings.watchProjects), asked at each shell command. A home that does is a
|
|
182
|
+
* live project: the owner and agents write there while a command runs, so the shell guard undoes only what the command
|
|
183
|
+
* names as its writes and leaves the rest. Not given: a controlled trial, where nobody else writes (BQ).
|
|
184
|
+
*/
|
|
185
|
+
readonly watchesProjects?: () => boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A candidate root that was not used because it is too broad, kept so it can be shown and corrected. */
|
|
189
|
+
export interface RefusedRoot {
|
|
190
|
+
readonly path: string;
|
|
191
|
+
readonly from: 'toolchain' | 'skill';
|
|
192
|
+
readonly reason: string;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Gather the allowed roots and allowed individual files from the project, its assets, toolchain and loaded resources.
|
|
197
|
+
* Roots that do not come from the project itself — the toolchain and the folders of the skills pi loaded — are checked
|
|
198
|
+
* for breadth first; a refused one is returned in `refused` instead of becoming a root.
|
|
199
|
+
*/
|
|
200
|
+
export function allowedRoots(deps: ReadBoundaryDeps): { roots: AllowedRoot[]; files: string[]; refused: RefusedRoot[] } {
|
|
201
|
+
const { project, store } = deps;
|
|
202
|
+
const loader = deps.resolveLoader();
|
|
203
|
+
const roots: AllowedRoot[] = [];
|
|
204
|
+
const files: string[] = [];
|
|
205
|
+
const refused: RefusedRoot[] = [];
|
|
206
|
+
const breadth: BreadthContext = {
|
|
207
|
+
home: deps.home ?? homedir(),
|
|
208
|
+
projectKeeperHome: deps.projectKeeperHome ?? dirname(dirname(store.dir)), // store.dir === <home>/projects/<projectId>
|
|
209
|
+
projectLocations: project.locations,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Project scope: every item except the ones outside the project and the whole session directory.
|
|
213
|
+
for (const item of project.scope) {
|
|
214
|
+
if (item.relation === 'Excluded' || item.category === 'Session source' || item.missing) continue;
|
|
215
|
+
roots.push({ path: item.path, label: `${item.relation} (${item.category})` });
|
|
216
|
+
}
|
|
217
|
+
// Also the project's own given locations, in case scope discovery has not run yet.
|
|
218
|
+
for (const loc of project.locations) roots.push({ path: loc, label: 'the project directory' });
|
|
219
|
+
// This project's own ProjectKeeper assets (store.dir === <home>/projects/<projectId>).
|
|
220
|
+
roots.push({ path: store.dir, label: "this project's ProjectKeeper assets" });
|
|
221
|
+
// The toolchain the project's own config points at (§6.7). Re-checked here, whatever the stored flag says.
|
|
222
|
+
for (const t of toolchainRoots(project, { home: breadth.home, projectKeeperHome: breadth.projectKeeperHome })) {
|
|
223
|
+
const reason = broadRootReason(t.path, breadth) ?? (t.used === false ? (t.notUsedReason ?? 'not used') : null);
|
|
224
|
+
if (reason) refused.push({ path: t.path, from: 'toolchain', reason });
|
|
225
|
+
else roots.push({ path: t.path, label: 'toolchain the build config points at' });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Global resources pi loaded (skills, extensions, prompt templates, context files) — not their credential files.
|
|
229
|
+
try {
|
|
230
|
+
if (loader) {
|
|
231
|
+
for (const s of loader.getSkills().skills as { filePath?: string; baseDir?: string }[]) {
|
|
232
|
+
if (!s.filePath) continue;
|
|
233
|
+
const dir = s.baseDir ?? dirname(s.filePath); // pi resolves a skill's relative references against this folder
|
|
234
|
+
const reason = broadRootReason(dir, breadth);
|
|
235
|
+
if (reason) { refused.push({ path: dir, from: 'skill', reason }); files.push(s.filePath); } // the skill file itself stays readable
|
|
236
|
+
else roots.push({ path: dir, label: 'a skill pi loaded' });
|
|
237
|
+
}
|
|
238
|
+
const ext = loader.getExtensions();
|
|
239
|
+
for (const e of ext.extensions as { path: string; resolvedPath?: string }[]) files.push(e.resolvedPath ?? e.path);
|
|
240
|
+
for (const p of loader.getPrompts().prompts as { filePath?: string }[]) if (p.filePath) files.push(p.filePath);
|
|
241
|
+
for (const f of loader.getAgentsFiles().agentsFiles as { path: string }[]) files.push(f.path);
|
|
242
|
+
const sys = loader.getSystemPromptSource();
|
|
243
|
+
if (sys?.path) files.push(sys.path);
|
|
244
|
+
for (const a of loader.getAppendSystemPromptSources() as { path: string }[]) files.push(a.path);
|
|
245
|
+
}
|
|
246
|
+
} catch { /* resources not loaded: the project scope and assets still bound the reads */ }
|
|
247
|
+
|
|
248
|
+
// Session logs that belong to this project — file by file, never the whole session directory (§3.1).
|
|
249
|
+
for (const src of store.sources.all()) if (src.anchor.kind === 'session') files.push(src.anchor.file);
|
|
250
|
+
|
|
251
|
+
return { roots, files, refused };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export interface ReadBoundary {
|
|
255
|
+
/** Inline extension that gates read/grep/find/ls/edit and bash/powershell before they run. */
|
|
256
|
+
readonly extension: InlineExtension;
|
|
257
|
+
/** Re-provided built-in tools: bash/powershell (credential-stripped) and read/edit/grep/ls (boundary-enforced). */
|
|
258
|
+
readonly tools: ToolDefinition[];
|
|
259
|
+
/** The boundary, exposed for tests and reporting (built lazily on first use). */
|
|
260
|
+
readonly boundary: Boundary;
|
|
261
|
+
readonly scratchDir: string;
|
|
262
|
+
setJobId(jobId: string, project?: Project, onDeny?: () => void): void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The file tools whose Operations receive the path pi has already resolved, so the boundary can be enforced there. */
|
|
266
|
+
export const GUARDABLE_FILE_TOOLS = ['read', 'edit', 'grep', 'ls'] as const;
|
|
267
|
+
export type GuardableFileTool = (typeof GUARDABLE_FILE_TOOLS)[number];
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A file tool re-provided through pi's own `create*ToolDefinition`, with Operations that check the boundary on the
|
|
271
|
+
* path pi has already resolved and then delegate to pi's default local behaviour (CKC-03 AC-23). This is a second
|
|
272
|
+
* layer under the gate: it does not depend on the gate's argument parsing, nor on the resolver copy in paths.ts —
|
|
273
|
+
* it sees exactly the absolute path the tool is about to open. `find` is not here: its default backend (fd) never
|
|
274
|
+
* hands its Operations the resolved search path, so `find` stays gated only. Nothing about the tool but its file
|
|
275
|
+
* access changes, so the offered tool and its schema are pi's (AC-4).
|
|
276
|
+
*/
|
|
277
|
+
export function guardedFileTool(name: GuardableFileTool, cwd: string, boundary: Boundary, onDeny?: () => void): ToolDefinition {
|
|
278
|
+
const guard = (absolutePath: string): void => {
|
|
279
|
+
const d = boundary.decide(absolutePath, absolutePath); // pi has already resolved it; cwd is irrelevant for an absolute path
|
|
280
|
+
if (!d.ok) { onDeny?.(); throw new Error(d.reason ?? `Out of the project's read boundary: ${absolutePath}`); }
|
|
281
|
+
};
|
|
282
|
+
switch (name) {
|
|
283
|
+
case 'read':
|
|
284
|
+
return createReadToolDefinition(cwd, { operations: {
|
|
285
|
+
readFile: (p) => { guard(p); return fsReadFile(p); },
|
|
286
|
+
access: (p) => { guard(p); return fsAccess(p, constants.R_OK); },
|
|
287
|
+
detectImageMimeType: detectSupportedImageMimeTypeFromFile,
|
|
288
|
+
} }) as unknown as ToolDefinition;
|
|
289
|
+
case 'edit':
|
|
290
|
+
return createEditToolDefinition(cwd, { operations: {
|
|
291
|
+
readFile: (p) => { guard(p); return fsReadFile(p); },
|
|
292
|
+
writeFile: (p, content) => { guard(p); return fsWriteFile(p, content, 'utf-8'); },
|
|
293
|
+
access: (p) => { guard(p); return fsAccess(p, constants.R_OK | constants.W_OK); },
|
|
294
|
+
} }) as unknown as ToolDefinition;
|
|
295
|
+
case 'grep':
|
|
296
|
+
return createGrepToolDefinition(cwd, { operations: {
|
|
297
|
+
isDirectory: async (p) => { guard(p); return (await fsStat(p)).isDirectory(); },
|
|
298
|
+
readFile: (p) => { guard(p); return fsReadFile(p, 'utf-8'); },
|
|
299
|
+
} }) as unknown as ToolDefinition;
|
|
300
|
+
case 'ls':
|
|
301
|
+
return createLsToolDefinition(cwd, { operations: {
|
|
302
|
+
exists: (p) => { guard(p); return fsAccess(p, constants.F_OK).then(() => true, () => false); },
|
|
303
|
+
stat: (p) => { guard(p); return fsStat(p); },
|
|
304
|
+
readdir: (p) => { guard(p); return fsReaddir(p); },
|
|
305
|
+
} }) as unknown as ToolDefinition;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
interface PiExtensionApi {
|
|
310
|
+
on(event: 'tool_call', handler: (event: ToolCallLike) => { block?: boolean; reason?: string } | undefined): void;
|
|
311
|
+
}
|
|
312
|
+
interface ToolCallLike {
|
|
313
|
+
readonly toolName: string;
|
|
314
|
+
readonly input: Record<string, unknown>;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Tools whose path argument is a read (edit is included: its diff returns file content — Spec §3.1). */
|
|
318
|
+
const PATH_ARG_TOOLS = new Set(['read', 'grep', 'find', 'ls', 'edit']);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A path as a message names it: from its project root on when it is inside one of `roots` (canonical keys, like the
|
|
322
|
+
* paths) — with the root's own name in front when the project has several — and whole otherwise. A step's record keeps
|
|
323
|
+
* only the start of a result, so a path given from the drive on names the directories above the project and loses the
|
|
324
|
+
* file where the project sits deep.
|
|
325
|
+
*/
|
|
326
|
+
export function shortPath(path: string, roots: readonly string[]): string {
|
|
327
|
+
const root = roots.filter((r) => path === r || path.startsWith(r.endsWith(sep) ? r : r + sep)).sort((a, b) => b.length - a.length)[0];
|
|
328
|
+
if (root === undefined) return path;
|
|
329
|
+
const rel = path.slice(root.length).split(sep).filter(Boolean).join('/') || '.';
|
|
330
|
+
return roots.length > 1 ? `${basename(root)}/${rel}` : rel;
|
|
331
|
+
}
|
|
332
|
+
/** Up to eight paths, then how many more. */
|
|
333
|
+
const listPaths = (paths: readonly string[], roots: readonly string[]): string => `${paths.slice(0, 8).map((p) => shortPath(p, roots)).join(', ')}${paths.length > 8 ? ` (+${paths.length - 8} more)` : ''}`;
|
|
334
|
+
|
|
335
|
+
type ToolResult = Awaited<ReturnType<ToolDefinition['execute']>>;
|
|
336
|
+
/** A shell result with `note` put first in its text: the step's record keeps the start of the result (BQ). */
|
|
337
|
+
function noteFirst(result: ToolResult, note: string): ToolResult {
|
|
338
|
+
const content = [...(result.content ?? [])];
|
|
339
|
+
const at = content.findIndex((part) => part.type === 'text');
|
|
340
|
+
if (at < 0) content.unshift({ type: 'text', text: note });
|
|
341
|
+
else { const part = content[at] as { type: 'text'; text: string }; content[at] = { ...part, text: `${note}\n\n${part.text}` }; }
|
|
342
|
+
return { ...result, content };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function createReadBoundary(deps: ReadBoundaryDeps): ReadBoundary {
|
|
346
|
+
const cwd = deps.project.locations[0]!;
|
|
347
|
+
const agentDirKey = canonicalKey(deps.agentDir, deps.agentDir);
|
|
348
|
+
let activeProject = deps.project;
|
|
349
|
+
let projectRoots = shellProjectRoots(activeProject);
|
|
350
|
+
let excludedRoots = activeProject.scope.filter((item) => item.relation === 'Excluded' && !item.missing).map((item) => item.path);
|
|
351
|
+
const keeperHome = dirname(dirname(deps.store.dir));
|
|
352
|
+
const guardHome = join(keeperHome, 'shell-guards');
|
|
353
|
+
const projectScratchKey = createHash('sha256').update(deps.project.id).digest('hex').slice(0, 24);
|
|
354
|
+
const scratchFor = (jobId: string) => join(keeperHome, 'scratch', projectScratchKey, createHash('sha256').update(jobId).digest('hex').slice(0, 24));
|
|
355
|
+
const ensureScratchOutsideProject = (path: string) => {
|
|
356
|
+
const key = canonicalKey(path, path);
|
|
357
|
+
if (projectRoots.some((root) => key === root || key.startsWith(root.endsWith(sep) ? root : root + sep))) {
|
|
358
|
+
throw new Error('Keeper scratch directory is inside the project; refusing to run shell tools');
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
let currentJobId = deps.jobId ?? randomUUID();
|
|
362
|
+
let scratchDir = scratchFor(currentJobId);
|
|
363
|
+
ensureScratchOutsideProject(scratchDir);
|
|
364
|
+
mkdirSync(scratchDir, { recursive: true });
|
|
365
|
+
let onDeny = deps.onDeny;
|
|
366
|
+
let spawningScratch: string | null = null;
|
|
367
|
+
const outputPaths = new Map<string, string>();
|
|
368
|
+
let boundary: Boundary | null = null;
|
|
369
|
+
const denyCredentialFile = credentialFileDeny(agentDirKey);
|
|
370
|
+
const get = (): Boundary => {
|
|
371
|
+
if (!boundary) {
|
|
372
|
+
const { roots, files } = allowedRoots({ ...deps, project: activeProject });
|
|
373
|
+
const excludedKeys = excludedRoots.map((path) => canonicalKey(path, path));
|
|
374
|
+
boundary = makeBoundary({
|
|
375
|
+
roots: [...roots, { path: scratchDir, label: "this job's scratch directory" }], files,
|
|
376
|
+
denyFile: (key) => excludedKeys.some((root) => key === root || key.startsWith(root.endsWith(sep) ? root : root + sep))
|
|
377
|
+
? 'Refused: this Excluded project location is outside Keeper\'s read boundary.' : denyCredentialFile(key),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return boundary;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const rememberPiOutput = (shell: 'bash' | 'powershell', path: unknown, jobId: string): void => {
|
|
384
|
+
if (typeof path !== 'string') return;
|
|
385
|
+
if (!new RegExp(`^pi-${shell}-[0-9a-f]{16}\\.log$`).test(basename(path))) return;
|
|
386
|
+
if (canonicalKey(dirname(path), dirname(path)) !== canonicalKey(tmpdir(), tmpdir())) return;
|
|
387
|
+
try { if (lstatSync(path).isFile()) outputPaths.set(jobId, path); } catch { /* no completed output file */ }
|
|
388
|
+
};
|
|
389
|
+
const bounded: Boundary = {
|
|
390
|
+
decide: (path, from) => {
|
|
391
|
+
const latestOutputPath = outputPaths.get(currentJobId);
|
|
392
|
+
if (latestOutputPath && canonicalKey(path, from) === canonicalKey(latestOutputPath, latestOutputPath)) {
|
|
393
|
+
try { if (lstatSync(latestOutputPath).isFile()) return { ok: true, reason: null }; } catch { /* fall through */ }
|
|
394
|
+
}
|
|
395
|
+
return get().decide(path, from);
|
|
396
|
+
},
|
|
397
|
+
describe: () => get().describe(),
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const factory = (pi: PiExtensionApi) => {
|
|
401
|
+
pi.on('tool_call', (event) => {
|
|
402
|
+
const b = bounded;
|
|
403
|
+
if (PATH_ARG_TOOLS.has(event.toolName)) {
|
|
404
|
+
const path = typeof event.input.path === 'string' ? event.input.path : null;
|
|
405
|
+
if (!path) return undefined; // grep/find/ls default to the cwd, which is in bounds
|
|
406
|
+
const d = b.decide(path, cwd);
|
|
407
|
+
if (!d.ok) { onDeny?.(); return { block: true, reason: d.reason ?? 'out of the read boundary' }; }
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
if (event.toolName === 'bash' || event.toolName === 'powershell') {
|
|
411
|
+
const command = typeof event.input.command === 'string' ? event.input.command : '';
|
|
412
|
+
if (!command) return undefined;
|
|
413
|
+
const d = planShellCommand(event.toolName, command, cwd, b, projectRoots, scratchDir, excludedRoots).decision;
|
|
414
|
+
if (!d.ok) { onDeny?.(); return { block: true, reason: d.reason ?? 'out of the read boundary' }; }
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Re-provide the built-in tools pi already has active: bash/powershell with a spawn hook and scoped journal,
|
|
422
|
+
// and read/edit/grep/ls with boundary-enforcing Operations. Only tools in pi's default-active set are
|
|
423
|
+
// re-provided, and each keeps pi's own definition, so the offered tool set and schema are unchanged (CKC-03 AC-4).
|
|
424
|
+
const activeBuiltins = new Set(deps.settingsManager.getDefaultTools() ?? ['read', 'bash', 'edit', 'write']);
|
|
425
|
+
const spawnHook = (ctx: { command: string; cwd: string; env: NodeJS.ProcessEnv }) => ({
|
|
426
|
+
...ctx, env: { ...shellEnv(ctx.env), TMPDIR: spawningScratch ?? scratchDir, TEMP: spawningScratch ?? scratchDir, TMP: spawningScratch ?? scratchDir },
|
|
427
|
+
});
|
|
428
|
+
const protectShell = (shell: 'bash' | 'powershell', base: ToolDefinition): ToolDefinition => ({
|
|
429
|
+
...base,
|
|
430
|
+
description: `${base.description} Write temporary files under $TMPDIR (also available as $TEMP and $TMP), a private directory for this job inside Keeper's home. Shell commands cannot write project files, including projectkeeper/. Use pk_* tools for that folder.`,
|
|
431
|
+
promptGuidelines: [...(base.promptGuidelines ?? []), 'Write temporary files under $TMPDIR; $TEMP and $TMP point to the same private job directory. Shell cannot write project files, including projectkeeper/.'],
|
|
432
|
+
executionMode: 'sequential',
|
|
433
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
434
|
+
return withShellWriteLock(async () => {
|
|
435
|
+
const callJobId = currentJobId;
|
|
436
|
+
const callScratch = scratchDir;
|
|
437
|
+
const callRoots = projectRoots;
|
|
438
|
+
const callExcluded = excludedRoots;
|
|
439
|
+
const denyForCall = onDeny;
|
|
440
|
+
const othersWrite = deps.watchesProjects?.() ?? false;
|
|
441
|
+
const command = (params as { command?: string }).command ?? '';
|
|
442
|
+
const plan = planShellCommand(shell, command, ctx?.cwd || cwd, bounded, callRoots, callScratch, callExcluded);
|
|
443
|
+
if (!plan.decision.ok) { denyForCall?.(); throw new Error(plan.decision.reason ?? 'Shell command refused'); }
|
|
444
|
+
outputPaths.delete(callJobId);
|
|
445
|
+
let snapshot: ShellProjectSnapshot;
|
|
446
|
+
try { snapshot = await ShellProjectSnapshot.take(callRoots, guardHome, plan.locations, callExcluded, { othersWrite, writes: plan.writePaths }); }
|
|
447
|
+
catch (error) { if (/^Error: Refused:/.test(String(error))) denyForCall?.(); throw error; }
|
|
448
|
+
let result: Awaited<ReturnType<ToolDefinition['execute']>> | undefined;
|
|
449
|
+
let shellError: unknown;
|
|
450
|
+
try {
|
|
451
|
+
spawningScratch = callScratch;
|
|
452
|
+
result = await base.execute(toolCallId, params, signal, (update) => {
|
|
453
|
+
rememberPiOutput(shell, (update.details as { fullOutputPath?: unknown } | undefined)?.fullOutputPath, callJobId);
|
|
454
|
+
onUpdate?.(update);
|
|
455
|
+
}, ctx);
|
|
456
|
+
rememberPiOutput(shell, (result.details as { fullOutputPath?: unknown } | undefined)?.fullOutputPath, callJobId);
|
|
457
|
+
} catch (error) { shellError = error; }
|
|
458
|
+
finally { spawningScratch = null; }
|
|
459
|
+
let changed: string[];
|
|
460
|
+
try { changed = await snapshot.restore(); }
|
|
461
|
+
catch (error) {
|
|
462
|
+
throw new Error(`Shell project check could not restore the project. Recovery copy: ${snapshot.recoveryPath}. ${String(error)}`, { cause: error });
|
|
463
|
+
}
|
|
464
|
+
// What changed while the command ran, not by it, stays as it is; the step says so first, where its record shows it.
|
|
465
|
+
const left = snapshot.left.length ? `${LEFT_NOTE}; left as it is: ${listPaths(snapshot.left, callRoots)}.` : '';
|
|
466
|
+
if (changed.length) {
|
|
467
|
+
outputPaths.delete(callJobId);
|
|
468
|
+
denyForCall?.();
|
|
469
|
+
throw new Error(`Shell changed project files; the changes were restored: ${listPaths(changed, callRoots)}.${left ? ` ${left}` : ''} Write temporary files under ${callScratch} (TMPDIR/TEMP/TMP). Use pk_* tools for projectkeeper/.`);
|
|
470
|
+
}
|
|
471
|
+
if (shellError) {
|
|
472
|
+
if (!left) throw shellError;
|
|
473
|
+
throw new Error(`${left}\n\n${shellError instanceof Error ? shellError.message : String(shellError)}`, { cause: shellError });
|
|
474
|
+
}
|
|
475
|
+
return left ? noteFirst(result!, left) : result!;
|
|
476
|
+
});
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
const tools: ToolDefinition[] = [];
|
|
480
|
+
if (activeBuiltins.has('bash')) {
|
|
481
|
+
tools.push(protectShell('bash', createBashToolDefinition(cwd, {
|
|
482
|
+
commandPrefix: deps.settingsManager.getShellCommandPrefix(),
|
|
483
|
+
shellPath: deps.settingsManager.getShellPath(),
|
|
484
|
+
spawnHook,
|
|
485
|
+
}) as unknown as ToolDefinition));
|
|
486
|
+
}
|
|
487
|
+
if (activeBuiltins.has('powershell') && process.platform === 'win32') {
|
|
488
|
+
tools.push(protectShell('powershell', createPowerShellToolDefinition(cwd, { spawnHook }) as unknown as ToolDefinition));
|
|
489
|
+
}
|
|
490
|
+
for (const name of GUARDABLE_FILE_TOOLS) {
|
|
491
|
+
if (activeBuiltins.has(name)) tools.push(guardedFileTool(name, cwd, bounded, () => onDeny?.()));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
extension: { name: 'projectkeeper-read-boundary', factory } as unknown as InlineExtension,
|
|
496
|
+
tools, boundary: bounded,
|
|
497
|
+
get scratchDir() { return scratchDir; },
|
|
498
|
+
setJobId(jobId: string, project?: Project, deny?: () => void) {
|
|
499
|
+
if (project) { activeProject = project; projectRoots = shellProjectRoots(project); excludedRoots = project.scope.filter((item) => item.relation === 'Excluded' && !item.missing).map((item) => item.path); }
|
|
500
|
+
currentJobId = jobId;
|
|
501
|
+
if (deny) onDeny = deny;
|
|
502
|
+
scratchDir = scratchFor(jobId);
|
|
503
|
+
ensureScratchOutsideProject(scratchDir);
|
|
504
|
+
mkdirSync(scratchDir, { recursive: true });
|
|
505
|
+
outputPaths.delete(jobId);
|
|
506
|
+
boundary = null;
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|