gentle-pi 4.1.0 → 4.3.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 +352 -128
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +188 -35
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +40 -8
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +20 -12
- package/docs/install-wizard.md +17 -10
- package/docs/permission-modes.md +51 -6
- package/docs/readme-reference.md +155 -31
- package/docs/session-profile-format.md +39 -0
- package/docs/yolo-mode.md +21 -7
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +146 -28
- package/extensions/gentle-ai.ts +380 -68
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +53 -43
- package/extensions/gentle-todo.ts +62 -18
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +215 -36
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +251 -35
- package/lib/auto-permission-tool-context.ts +61 -0
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +215 -28
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +62 -8
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +146 -12
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/model-routing-authority.ts +6 -1
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/selection-engine.ts +4 -1
- package/lib/session-profile-freeze.ts +40 -16
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +2 -0
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-todo.ts +39 -8
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/vim-editor-adapter.ts +22 -4
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +5 -6
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +61 -7
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +144 -10
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-wizard-preview.mjs +2 -2
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/pi-update.mjs +223 -0
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +23 -5
- package/tests/auto-mode-sdk.test.ts +29 -2
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +353 -2
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +270 -12
- package/tests/autonomous-guard.test.ts +208 -16
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +35 -7
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +443 -8
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +180 -17
- package/tests/gentle-shell-bundled.test.ts +235 -38
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +97 -13
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +113 -14
- package/tests/gentle-todo.test.ts +258 -27
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/install-wizard.test.ts +2 -2
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +3 -3
- package/tests/installer-probes.test.ts +8 -8
- package/tests/installer-runner.test.ts +14 -13
- package/tests/installer-server.test.ts +13 -5
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/main-channel-upgrade.test.ts +7 -1
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +28 -24
- package/tests/pi-update-launcher.test.ts +106 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +131 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/selection-engine.test.ts +35 -5
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +37 -1
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +18 -0
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-todo.test.ts +15 -12
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/vim-editor-adapter.test.ts +41 -15
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +24 -1
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -5,21 +5,178 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
7
7
|
|
|
8
|
-
const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle,
|
|
8
|
+
const { classifyGuardedCommand, evaluateGuardedCommand, guardedCommandPreview, guardedCommandTitle, isReversibleYoloCommand, yoloConfiguredRestriction } = __testing;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// gentle-shell#2172: YOLO waives every reversible push and non-interactive
|
|
11
|
+
// rebase segment; anything that deletes, rewrites or publishes keeps asking.
|
|
12
|
+
test("YOLO waives reversible push and rebase segments after full evaluation (#2172)", () => {
|
|
11
13
|
const config = { autonomousMode: false, guardedCommands: {} };
|
|
12
|
-
for (const command of [
|
|
13
|
-
|
|
14
|
+
for (const command of [
|
|
15
|
+
"git push", "git push -u origin feature/test", "git push --set-upstream origin main", "git push origin HEAD",
|
|
16
|
+
"git push -q origin main", "git push --quiet --tags", "git push --no-verify origin main", "git push --follow-tags -v origin main",
|
|
17
|
+
"git push origin HEAD:refs/heads/feature/x", "git push origin main:main",
|
|
18
|
+
"git -C /other push origin main", "env X=1 git push origin main",
|
|
19
|
+
"git add -A && git commit -m 'fix: x && y' && git push -u origin feature/test",
|
|
20
|
+
"git push origin main && gh pr create --fill", "cd sub && git push", "git push origin main; git status",
|
|
21
|
+
"git rebase main", "git rebase origin/main", "git rebase --onto main feature~3 feature", "git rebase --continue", "git rebase --abort", "git rebase --autostash main",
|
|
22
|
+
"git fetch origin && git rebase origin/main && git push",
|
|
23
|
+
"git\tpush\torigin\tmain", "git -C dir rebase main", "command git push origin main", "env -i git push origin main", "GIT_AUTHOR_NAME=x git push origin main",
|
|
24
|
+
]) {
|
|
25
|
+
assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), true, command);
|
|
14
26
|
}
|
|
15
27
|
for (const command of [
|
|
16
|
-
"git push --delete origin main", "git push origin :main", "git push --mirror origin",
|
|
17
|
-
"git push --prune origin", "git push -f origin main", "git -
|
|
18
|
-
"git push origin main
|
|
19
|
-
"git push origin main && git
|
|
28
|
+
"git push --delete origin main", "git push -d origin main", "git push origin :main", "git push origin +main", "git push --mirror origin",
|
|
29
|
+
"git push --prune origin", "git push --all origin", "git push -f origin main", "git push --force-with-lease origin main",
|
|
30
|
+
"git push --receive-pack=evil origin main", "git push --exec=evil origin main", "git push --repo=/other origin main", "git push -o ci.skip origin main",
|
|
31
|
+
"sudo git push origin main", "git push origin main && npm publish", "git push origin main && pi remove foo", "git push origin main && git branch -D old",
|
|
20
32
|
`sh -c 'git push origin main'`, `git push origin main && sh -c "psql -c 'DROP TABLE users'"`,
|
|
21
|
-
"git push origin main && find data -delete", "git push origin main && rm -r data",
|
|
22
|
-
|
|
33
|
+
"git push origin main && find data -delete", "git push origin main && rm -r data", "git push origin main && git stash drop", "git push origin main && git reset --hard",
|
|
34
|
+
"git rebase -i HEAD~3", "git rebase --interactive main", "git rebase -x 'make test' main", "git rebase --exec 'make test' main",
|
|
35
|
+
"git commit -m x", "git status",
|
|
36
|
+
// Global git options that run or redirect code are not inspected by the segment grammar, so they never qualify.
|
|
37
|
+
"git -c core.sshCommand=evil push origin main", "git --exec-path=/evil push origin main", "git --git-dir=/x push origin main", "git -C dir rebase -i main",
|
|
38
|
+
"git push origin $(echo main)", "git push origin `echo main`", "GIT_SSH=\"evil\" git push origin main",
|
|
39
|
+
"sh -c 'git push; echo done'", "bash -c 'git push && echo done'", "sh -c 'git rebase main; echo done'", "eval 'git push origin main'", "sudo -u x git push origin main",
|
|
40
|
+
]) assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), false, command);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// YOLO also waives recursive removal of the session's own temp scratch: one
|
|
44
|
+
// literal path component under a temp root whose admission-time lstat is
|
|
45
|
+
// missing, a symlink, an own file or an own mktemp directory. Every other
|
|
46
|
+
// effect in the command keeps its ordinary guard.
|
|
47
|
+
test("YOLO waives recursive removal of own temp scratch only", () => {
|
|
48
|
+
const config = { autonomousMode: false, guardedCommands: {} };
|
|
49
|
+
const entry = (type: "dir" | "file" | "link", uid = 1000) => ({
|
|
50
|
+
uid, isDirectory: () => type === "dir", isFile: () => type === "file", isSymbolicLink: () => type === "link",
|
|
51
|
+
});
|
|
52
|
+
const entries = new Map([
|
|
53
|
+
["/tmp/tmp.8phya1BpFr", entry("dir")], ["/tmp/tmp.LKd1kBX8Rw", entry("dir")], ["/tmp/tmp.C94OVzf01o", entry("dir")],
|
|
54
|
+
["/tmp/ctx-backstop-pnpm-test.log", entry("file")], ["/tmp/tmp.abcdef1234", entry("dir")], ["/tmp/latest", entry("link", 0)],
|
|
55
|
+
["/var/tmp/tmp.zzzzzz", entry("dir")], ["/tmp/cache", entry("dir")], ["/tmp/tmp.other1234", entry("dir", 0)],
|
|
56
|
+
["/tmp/shared.log", entry("file", 0)],
|
|
57
|
+
]);
|
|
58
|
+
const fs = {
|
|
59
|
+
uid: 1000, tmpdir: "/tmp", home: "/home/user",
|
|
60
|
+
lstat(path: string) {
|
|
61
|
+
if (path === "/tmp/tmp.denied99") throw Object.assign(new Error("EACCES"), { code: "EACCES" });
|
|
62
|
+
const found = entries.get(path);
|
|
63
|
+
if (found === undefined) throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
|
|
64
|
+
return found;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
const waived = (command: string) => isReversibleYoloCommand(command, evaluateGuardedCommand(command, config), fs);
|
|
68
|
+
for (const command of [
|
|
69
|
+
"rm -rf /tmp/tmp.8phya1BpFr /tmp/tmp.LKd1kBX8Rw /tmp/tmp.C94OVzf01o /tmp/ctx-backstop-pnpm-test.log; cd /home/gentleman/work/gentle-pi-worktrees/harness-r1 && git log --oneline -1",
|
|
70
|
+
"rm -rf /tmp/tmp.abcdef1234", "rm -rf /tmp/ctx-backstop-pnpm-test.log", "rm -rf /tmp/latest", "rm -rf /tmp/tmp.gone1234",
|
|
71
|
+
"rm -r -f -- /var/tmp/tmp.zzzzzz", "rm -rf /tmp/tmp.abcdef1234 && git push -u origin feature/x",
|
|
72
|
+
"rm -rf /tmp/tmp.abcdef1234 && rm -rf /tmp/tmp.gone1234\nrm -rf /tmp/latest || git log --oneline -1",
|
|
73
|
+
]) assert.equal(waived(command), true, command);
|
|
74
|
+
for (const command of [
|
|
75
|
+
"rm -rf /tmp", "rm -rf /tmp/", "rm -rf /tmp/*", "rm -rf /tmp/tmp.x/", "rm -rf /tmp/tmp.x/../..", "rm -rf /tmp/./tmp.x",
|
|
76
|
+
"rm -rf /tmp/a/b", `rm -rf "$TMPDIR/x"`, "rm -rf $TMPDIR/x", "rm -rf /tmp/tmp.{a,b}", "rm -rf /tmp/tmp.abc?", "rm -rf tmp.abcdef1234",
|
|
77
|
+
"sudo rm -rf /tmp/tmp.abcdef1234", "xargs rm -rf /tmp/tmp.abcdef1234", "rm -rf --no-preserve-root /tmp/tmp.abcdef1234",
|
|
78
|
+
"rm -rf /tmp/tmp.abcdef1234 ~/foo", "rm -rf /tmp/tmp.abcdef1234 && rm -rf data", "rm -rf /tmp/tmp.abcdef1234 data",
|
|
79
|
+
"rm -rf /tmp/tmp.abcdef1234 && find /tmp/x -delete", "rm -rf /tmp/tmp.abcdef1234 && psql -c 'DROP TABLE users'",
|
|
80
|
+
"rm -rf /tmp/tmp.abcdef1234 && git push --delete origin main", "rm -rf /tmp/tmp.abcdef1234 && npm publish",
|
|
81
|
+
"rm -rf /tmp/tmp.abcdef1234 && git stash drop", "rm -rf /tmp/cache", "rm -rf /tmp/tmp.other1234", "rm -rf /tmp/shared.log",
|
|
82
|
+
"rm -rf /tmp/tmp.denied99", "rm -rf /home/user/tmp.abcdef1234", "sh -c 'rm -rf /tmp/tmp.abcdef1234'",
|
|
83
|
+
"find /tmp -maxdepth 1 -name 'tmp.*' -exec rm -rf {} +", "rm -rf /tmp/tmp.gone1234>important.txt", "rm -rf /tmp/tmp.gone1234 2>/dev/null",
|
|
84
|
+
// Ordering: an earlier segment can move data into a target the lstat saw as scratch.
|
|
85
|
+
"mv /home/u/data /tmp/tmp.missingZZ619976 && rm -rf /tmp/tmp.missingZZ619976", "mv /home/u/data /tmp/tmp.abcdef1234 && rm -rf /tmp/tmp.abcdef1234",
|
|
86
|
+
"cp -r x /tmp/tmp.abcdef1234; rm -rf /tmp/tmp.abcdef1234", "echo hi; rm -rf /tmp/tmp.abcdef1234", "ln -s /home/u/data /tmp/tmp.gone1234 && rm -rf /tmp/tmp.gone1234",
|
|
87
|
+
"(mv /home/u/data /tmp/tmp.abcdef1234) && rm -rf /tmp/tmp.abcdef1234", "(rm -rf /tmp/tmp.abcdef1234)",
|
|
88
|
+
"rm -rf /tmp/tmp.abcdef1234 & mv /home/u/data /tmp/tmp.abcdef1234", "rm -rf /tmp/tmp.abcdef1234 | cat",
|
|
89
|
+
"rm -rf /tmp/tmp.gone1234; mv /home/u/data /tmp/tmp.abcdef1234; rm -rf /tmp/tmp.abcdef1234",
|
|
90
|
+
]) assert.equal(waived(command), false, command);
|
|
91
|
+
// A tmpdir under home would make top-level repository files look like scratch.
|
|
92
|
+
const underHome = { ...fs, home: "/home/gentleman", tmpdir: "/home/gentleman/work/repo", lstat: () => entry("file") };
|
|
93
|
+
assert.equal(isReversibleYoloCommand("rm -rf /home/gentleman/work/repo/README.md", evaluateGuardedCommand("rm -rf /home/gentleman/work/repo/README.md", config), underHome), false);
|
|
94
|
+
// A missing uid (no POSIX ownership) never proves own scratch.
|
|
95
|
+
assert.equal(isReversibleYoloCommand("rm -rf /tmp/tmp.abcdef1234", evaluateGuardedCommand("rm -rf /tmp/tmp.abcdef1234", config), { ...fs, uid: undefined }), false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// An eligible push or rebase stays waived with fd or /dev/null redirections
|
|
99
|
+
// and a pipe into a read-only filter; any other redirection or consumer asks.
|
|
100
|
+
test("YOLO waives push and rebase segments with harmless redirections and read-only filters", () => {
|
|
101
|
+
const config = { autonomousMode: false, guardedCommands: {} };
|
|
102
|
+
const entry = { uid: 1000, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false };
|
|
103
|
+
const fs = { uid: 1000, tmpdir: "/tmp", home: "/home/user", lstat: () => entry };
|
|
104
|
+
const waived = (command: string) => isReversibleYoloCommand(command, evaluateGuardedCommand(command, config), fs);
|
|
105
|
+
for (const command of [
|
|
106
|
+
"git push origin main 2>&1", "git push -u origin feat/x 2>&1 | tail -5", "git push origin main >/dev/null", "git push origin main 2>/dev/null",
|
|
107
|
+
"git push origin main &>/dev/null", "git push origin main > /dev/null 2>&1", "git push origin main 2>&1 | grep -v hint | head -20",
|
|
108
|
+
"git push origin main | cat", "git push origin main 2>&1 | wc -l && gh pr create --fill", "git push 2>&1 | grep 'remote: '",
|
|
109
|
+
"git rebase origin/main 2>&1 | tail -3", "git fetch && git rebase origin/main >/dev/null && git push 2>&1 | tail -5",
|
|
110
|
+
"rm -rf /tmp/tmp.abcdef1234; git push origin main 2>&1 | tail -5",
|
|
111
|
+
]) assert.equal(waived(command), true, command);
|
|
112
|
+
for (const command of [
|
|
113
|
+
"git push origin main > out.txt", "git push origin main 2>err.log", "git push origin main >> /dev/null.txt", "git push origin main &>log",
|
|
114
|
+
"git push origin main | sh", "git push origin main | tee out", "git push origin main 2>&1 | tail -5 | sh", "git push origin main | tail -5 > out",
|
|
115
|
+
"git push origin main | catx", "git push origin main 2>&1 &", "git push origin main | tail -5 &", "git push origin main | grep \"$(id)\"",
|
|
116
|
+
"git push --force origin main 2>&1", "git push --delete origin main 2>&1 | tail -5", "git push origin :main 2>&1", "git push --mirror origin 2>&1",
|
|
117
|
+
"git rebase -i main 2>&1 | tail", "git push origin main 2>&1 | xargs echo",
|
|
118
|
+
]) assert.equal(waived(command), false, command);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// gentle-shell#2174: a guarded word inside an argument of a known data command
|
|
122
|
+
// (search, print, view, non-executing edit) is text, not an operation. Every
|
|
123
|
+
// opaque form keeps today's lexical match.
|
|
124
|
+
test("guarded words inside data-command arguments are not guarded operations (#2174)", () => {
|
|
125
|
+
const config = { autonomousMode: false, guardedCommands: {} };
|
|
126
|
+
for (const command of [
|
|
127
|
+
"grep -n 'git push origin main' probe.mts",
|
|
128
|
+
"grep -o '\"command\":\"[^\"]*git push[^\"]*\"' session.jsonl | sort | uniq -c | sort -rn | head -8",
|
|
129
|
+
"rg 'npm publish' docs",
|
|
130
|
+
"echo 'remember to git push later'",
|
|
131
|
+
"cat > probe.mts <<'EOF'\nconst x = \"git push --force origin main\";\nEOF",
|
|
132
|
+
"grep -rn 'git reset --hard' docs",
|
|
133
|
+
"env LC_ALL=C grep 'pi remove' notes.md",
|
|
134
|
+
"grep -l \"git push\" a.jsonl",
|
|
135
|
+
// A data-only command substitution or a plain assignment keeps the exemption.
|
|
136
|
+
"f=$(ls); grep -l \"git push\" $f",
|
|
137
|
+
"cd x; f=$(ls -t */*.jsonl | head -40); grep -l \"git push\" $f 2>/dev/null | head -5",
|
|
138
|
+
"grep -l 'git push' $(ls *.jsonl)", "f=1; grep 'git push' x", "grep \"git push $(pwd)\" x", "f=$(ls); grep -rn 'git reset --hard' docs",
|
|
139
|
+
]) assert.equal(classifyGuardedCommand(command, config), "not-guarded", command);
|
|
140
|
+
for (const [command, expected] of [
|
|
141
|
+
["git push origin main", "confirm"],
|
|
142
|
+
["grep x file && git push origin main", "confirm"],
|
|
143
|
+
["sh -c 'git push origin main'", "confirm"],
|
|
144
|
+
["bash <<'EOF'\ngit push origin main\nEOF", "confirm"],
|
|
145
|
+
["python3 -c \"import os; os.system('git push')\"", "confirm"],
|
|
146
|
+
["echo $(git push origin main)", "confirm"],
|
|
147
|
+
["x=$(ls); git push origin main", "confirm"],
|
|
148
|
+
["x=$(git push origin main); grep 'git push' $x", "confirm"],
|
|
149
|
+
["f=$(ls); $f push origin main; grep 'git push' x", "confirm"],
|
|
150
|
+
["grep 'git push' $(cat x | sh)", "confirm"],
|
|
151
|
+
["grep 'git push' $(echo $(git push))", "confirm"],
|
|
152
|
+
["grep 'git push' $((1+2))", "confirm"],
|
|
153
|
+
["cat <<EOF\ndon't $(git push origin main)\nEOF", "confirm"],
|
|
154
|
+
["PATH=/x; git push origin main", "confirm"],
|
|
155
|
+
["x=$(ls); git reset --hard", "block"],
|
|
156
|
+
["grep 'git push origin main' f | sh", "confirm"],
|
|
157
|
+
["time git push origin main", "confirm"],
|
|
158
|
+
["sed 's/x/git push/e' file", "confirm"],
|
|
159
|
+
["grep \"git push origin main file", "confirm"],
|
|
160
|
+
["ssh host git push origin main", "confirm"],
|
|
161
|
+
["cat <<'EOF' | bash\ngit push -f origin main\nEOF", "block"],
|
|
162
|
+
["git reset --hard", "block"],
|
|
163
|
+
["echo ok; git push --force origin main", "block"],
|
|
164
|
+
// Verifier counterexamples: every executor or unproven heredoc keeps the lexical guard.
|
|
165
|
+
["cat <<EOF; git push origin main\nhello\nEOF", "confirm"],
|
|
166
|
+
["cat <<EOF; chmod -R 777 /tmp/guard-target\nhello\nEOF", "block"],
|
|
167
|
+
["echo '<<true'\ngit push --force origin main\ntrue", "block"],
|
|
168
|
+
["printf '%s\\n' 'git push --force origin main' | xargs env", "block"],
|
|
169
|
+
["printf '%s\\n' 'git push origin main' | sudo -s", "confirm"],
|
|
170
|
+
["printf '%s\\n' 'git push origin main' | env -S 'sh -s'", "confirm"],
|
|
171
|
+
["printf '%s\\n' 'git push origin main' | exec -a cat sh", "confirm"],
|
|
172
|
+
["printf '%s\\n' 'git push origin main' | builtin source /dev/stdin", "confirm"],
|
|
173
|
+
["printf '%s\\n' 'git push origin main' | $'sh'", "confirm"],
|
|
174
|
+
["printf x | sed 's|.*|git push --force origin main|e'", "block"],
|
|
175
|
+
["printf x | sed '1e git push origin main'", "confirm"],
|
|
176
|
+
["echo 'git push origin main' | at now", "confirm"],
|
|
177
|
+
["echo 'git push origin main' > x.sh && ./x.sh", "confirm"],
|
|
178
|
+
["sed -i '' 's/a/git push/' file", "confirm"],
|
|
179
|
+
] as const) assert.equal(classifyGuardedCommand(command, config), expected, command);
|
|
23
180
|
});
|
|
24
181
|
|
|
25
182
|
test("YOLO preserves explicit push restrictions in either config layer independently of persistent autonomy", () => {
|
|
@@ -27,18 +184,23 @@ test("YOLO preserves explicit push restrictions in either config layer independe
|
|
|
27
184
|
const cwd = join(root, "repo");
|
|
28
185
|
const home = join(root, "home");
|
|
29
186
|
try {
|
|
30
|
-
|
|
187
|
+
const pushOnly = (): unknown => yoloConfiguredRestriction(cwd, ["gitPush"], { gentlePiConfigHome: home });
|
|
188
|
+
assert.equal(pushOnly(), undefined);
|
|
31
189
|
writeConfig(home, "runtime-guardrails.json", { autonomousMode: false, guardedCommands: { gitPush: "confirm" } });
|
|
32
190
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { autonomousMode: true, guardedCommands: { gitPush: "allow" } });
|
|
33
|
-
assert.equal(
|
|
191
|
+
assert.equal(pushOnly(), "confirm");
|
|
34
192
|
writeConfig(home, "runtime-guardrails.json", { autonomousMode: true, guardedCommands: { gitPush: "allow" } });
|
|
35
|
-
assert.equal(
|
|
193
|
+
assert.equal(pushOnly(), undefined);
|
|
36
194
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitPush: "block" } });
|
|
37
|
-
assert.equal(
|
|
195
|
+
assert.equal(pushOnly(), "block");
|
|
38
196
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitPush: "confirm" } });
|
|
39
|
-
assert.equal(
|
|
197
|
+
assert.equal(pushOnly(), "confirm");
|
|
40
198
|
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", null);
|
|
41
|
-
assert.equal(
|
|
199
|
+
assert.equal(pushOnly(), "confirm");
|
|
200
|
+
// gentle-shell#2172: a rebase rule restricts only commands that rebase.
|
|
201
|
+
writeConfig(cwd, ".pi/gentle-ai/runtime-guardrails.json", { guardedCommands: { gitRebase: "block" } });
|
|
202
|
+
assert.equal(pushOnly(), undefined, "a rebase rule does not restrict a push-only command");
|
|
203
|
+
assert.equal(yoloConfiguredRestriction(cwd, ["gitPush", "gitRebase"], { gentlePiConfigHome: home }), "block");
|
|
42
204
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
43
205
|
});
|
|
44
206
|
|
|
@@ -75,6 +237,36 @@ test("quoted separator arguments cannot waive hard denial or invent destruction"
|
|
|
75
237
|
}
|
|
76
238
|
});
|
|
77
239
|
|
|
240
|
+
// gentle-pi#2285: compound bodies, backticks and eval reach the data-loss
|
|
241
|
+
// boundary, while the YOLO scratch waiver still covers leading direct `rm` only.
|
|
242
|
+
test("removal after reserved words, in backticks and in eval needs fresh confirmation and is never scratch-waived", () => {
|
|
243
|
+
const config = { autonomousMode: true, guardedCommands: { gitPush: "allow" as const } };
|
|
244
|
+
for (const command of [`if true; then rm -rf data; fi`, `for d in a b; do rm -rf "$d"; done`, "echo `rm -rf data`", `eval 'rm -rf data'`]) {
|
|
245
|
+
const evaluation = evaluateGuardedCommand(command, config);
|
|
246
|
+
assert.equal(evaluation.action, "confirm", command);
|
|
247
|
+
assert.equal(evaluation.dataLoss, true, command);
|
|
248
|
+
}
|
|
249
|
+
for (const command of [`if true; then rm -rf ~; fi`, "echo `rm -rf /`", `eval 'rm -rf "$HOME"'`]) {
|
|
250
|
+
assert.equal(classifyGuardedCommand(command, config), "block", command);
|
|
251
|
+
}
|
|
252
|
+
for (const command of [`if [ -f x ]; then echo ok; fi`, `for f in *; do cat "$f"; done`, "echo `date`", `eval "$(ssh-agent -s)"`, `grep "then rm -rf" x`]) {
|
|
253
|
+
assert.equal(classifyGuardedCommand(command, config), "not-guarded", command);
|
|
254
|
+
}
|
|
255
|
+
const entry = { uid: 1000, isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false };
|
|
256
|
+
const fs = { uid: 1000, tmpdir: "/tmp", home: "/home/user", lstat: () => entry };
|
|
257
|
+
const normal = { autonomousMode: false, guardedCommands: {} };
|
|
258
|
+
assert.equal(isReversibleYoloCommand("rm -rf /tmp/tmp.abcdef1234", evaluateGuardedCommand("rm -rf /tmp/tmp.abcdef1234", normal), fs), true);
|
|
259
|
+
for (const command of [
|
|
260
|
+
`if true; then rm -rf /tmp/tmp.abcdef1234; fi`, `for d in a; do rm -rf /tmp/tmp.abcdef1234; done`, `{ rm -rf /tmp/tmp.abcdef1234; }`,
|
|
261
|
+
`! rm -rf /tmp/tmp.abcdef1234`, "echo `rm -rf /tmp/tmp.abcdef1234`", `eval 'rm -rf /tmp/tmp.abcdef1234'`, `eval rm -rf /tmp/tmp.abcdef1234`,
|
|
262
|
+
"rm -rf /tmp/tmp.abcdef1234 && echo `rm -rf data`", `rm -rf /tmp/tmp.abcdef1234; eval 'rm -rf data'`,
|
|
263
|
+
]) {
|
|
264
|
+
const evaluation = evaluateGuardedCommand(command, normal);
|
|
265
|
+
assert.equal(evaluation.action, "confirm", command);
|
|
266
|
+
assert.equal(isReversibleYoloCommand(command, evaluation, fs), false, command);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
78
270
|
// ---------------------------------------------------------------------------
|
|
79
271
|
// Helpers
|
|
80
272
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, symlinkSync } from "node:fs";
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import { __testing } from "../extensions/gentle-ai.ts";
|
|
@@ -82,9 +82,17 @@ test("explicit review protects canonical HOME aliases, filesystem root and sensi
|
|
|
82
82
|
mkdirSync(home);
|
|
83
83
|
mkdirSync(join(home, ".ssh"));
|
|
84
84
|
symlinkSync(home, alias);
|
|
85
|
-
const
|
|
85
|
+
const previousHome = process.env.HOME;
|
|
86
|
+
const previousProfile = process.env.USERPROFILE;
|
|
87
|
+
t.after(() => {
|
|
88
|
+
if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome;
|
|
89
|
+
if (previousProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = previousProfile;
|
|
90
|
+
rmSync(root, { recursive: true, force: true });
|
|
91
|
+
});
|
|
92
|
+
// Node resolves the home from USERPROFILE on Windows and HOME on POSIX.
|
|
86
93
|
process.env.HOME = home;
|
|
87
|
-
|
|
94
|
+
process.env.USERPROFILE = home;
|
|
95
|
+
assert.equal(realpathSync(homedir()), home, "the protected home must be the isolated fixture");
|
|
88
96
|
let calls = 0;
|
|
89
97
|
const native = { reviewMode: async () => ({ operation: "status", status: { effective: "on", source: "global", global: "on", cloneLocal: "" } }), targetStatus: async () => { calls++; throw new Error("protected native invocation"); } } as unknown as NativeReviewCli;
|
|
90
98
|
for (const target of [home, alias, "/", join(home, ".ssh")]) {
|
|
@@ -17,7 +17,7 @@ const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) =
|
|
|
17
17
|
const NODE = "24.21.0";
|
|
18
18
|
const PNPM = "11.1.1";
|
|
19
19
|
const NPM = "11.19.0";
|
|
20
|
-
const ENGRAM = "3.
|
|
20
|
+
const ENGRAM = "3.4.0";
|
|
21
21
|
|
|
22
22
|
// Every temporary home this file creates is removed when the file's tests end.
|
|
23
23
|
const roots: string[] = [];
|
|
@@ -373,8 +373,8 @@ posixTest("ensureEngram downloads nothing when the prefix is missing", async ()
|
|
|
373
373
|
});
|
|
374
374
|
|
|
375
375
|
test("engramExecutable takes only the top-level engram from the tarball and engram.exe from the zip, regular files only", () => {
|
|
376
|
-
const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
377
|
-
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
376
|
+
const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_linux_amd64.tar.gz" };
|
|
377
|
+
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_windows_amd64.zip" };
|
|
378
378
|
assert.deepEqual(engramExecutable(engramArchive, tarball), engramBytes);
|
|
379
379
|
const exe = Buffer.from("MZ engram");
|
|
380
380
|
assert.deepEqual(engramExecutable(zip([["tools/cloud-sync-projects.ps1", Buffer.from("x")], ["engram.exe", exe]]), zipped), exe);
|
|
@@ -42,16 +42,26 @@ test("invalid or unreadable preference files read as default and are never overw
|
|
|
42
42
|
const dir = home();
|
|
43
43
|
mkdirSync(join(dir, "card-content.json"));
|
|
44
44
|
assert.equal(resolveCardContent({ gentlePiConfigHome: dir }).malformed, true, "a directory in its place is unreadable, not missing");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
assert.throws(() => writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: dir }), /Cannot update malformed or unreadable card content preference/);
|
|
46
|
+
assert.deepEqual(readdirSync(dir), ["card-content.json"], "the rejected write must not create temporary files");
|
|
47
|
+
assert.deepEqual(readdirSync(join(dir, "card-content.json")), [], "the unreadable directory is preserved");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// chmod cannot deny reads on Windows; retain the portable directory case above.
|
|
51
|
+
test("POSIX unreadable preference files read as default and are never overwritten", {
|
|
52
|
+
skip: process.platform === "win32" || process.getuid?.() === 0 ? "requires POSIX permissions and a non-root user" : false,
|
|
53
|
+
}, () => {
|
|
54
|
+
const locked = home();
|
|
55
|
+
writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: locked });
|
|
56
|
+
chmodSync(join(locked, "card-content.json"), 0o000);
|
|
57
|
+
try {
|
|
58
|
+
assert.deepEqual({ ...resolveCardContent({ gentlePiConfigHome: locked }), globalFile: "" }, { content: "default", source: "global_file", malformed: true, globalFile: "" });
|
|
59
|
+
assert.throws(() => writeCardContent(CARD_CONTENT.DEFAULT, { gentlePiConfigHome: locked }), /Cannot update malformed or unreadable card content preference/);
|
|
60
|
+
} finally {
|
|
61
|
+
chmodSync(join(locked, "card-content.json"), 0o600);
|
|
54
62
|
}
|
|
63
|
+
assert.equal(resolveCardContent({ gentlePiConfigHome: locked }).content, CARD_CONTENT.MINIMAL, "the rejected write preserves the stored preference");
|
|
64
|
+
assert.deepEqual(readdirSync(locked), ["card-content.json"], "the rejected write leaves no temporary files");
|
|
55
65
|
});
|
|
56
66
|
|
|
57
67
|
test("the live slot shares one content level per process and an unset slot reads default", (t) => {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import test from "node:test";
|
|
3
6
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
7
|
import { createChildSafetyExtension } from "../extensions/child-safety.ts";
|
|
@@ -59,6 +62,23 @@ test("headless children reject corrected literal forms without prompting", async
|
|
|
59
62
|
}
|
|
60
63
|
});
|
|
61
64
|
|
|
65
|
+
// gentle-pi#2285: removal behind reserved words, in backticks or in eval is still data loss.
|
|
66
|
+
test("children block destruction after reserved words, in backticks and in eval", async () => {
|
|
67
|
+
for (const primary of [false, true]) {
|
|
68
|
+
const h = harness(true, primary);
|
|
69
|
+
const ctx = context(async () => { throw new Error("child must never prompt"); }, false);
|
|
70
|
+
for (const command of [
|
|
71
|
+
`if true; then rm -rf data; fi`, `for d in a b; do rm -rf "$d"; done`, "echo `rm -rf data`", `eval 'rm -rf data'`,
|
|
72
|
+
`if true; then rm -rf ~; fi`, `{ rm -rf data; }`, `while true; do rm -rf data; done`,
|
|
73
|
+
]) {
|
|
74
|
+
assert.equal((await h.handlers[0](event(command), ctx) as { block?: boolean })?.block, true, command);
|
|
75
|
+
}
|
|
76
|
+
for (const command of [`if [ -f x ]; then echo ok; fi`, `for f in *; do cat "$f"; done`, "echo `date`", `grep "then rm -rf" x`]) {
|
|
77
|
+
assert.equal(await h.handlers[0](event(command), ctx), undefined, command);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
62
82
|
test("primary requires fresh confirmation, cancellation and missing UI block", async () => {
|
|
63
83
|
const h = harness(false, true);
|
|
64
84
|
const command = `psql -c 'DROP TABLE users'`;
|
|
@@ -81,6 +101,25 @@ test("primary requires fresh confirmation, cancellation and missing UI block", a
|
|
|
81
101
|
assert.deepEqual(blockers, [true, false, true, false, true, false, true, false]);
|
|
82
102
|
});
|
|
83
103
|
|
|
104
|
+
// The YOLO temp-scratch waiver is primary-only: children still block it and
|
|
105
|
+
// Normal mode still asks, whatever the targets look like on disk.
|
|
106
|
+
test("temp scratch removal stays blocked in children and confirmed in Normal mode", async () => {
|
|
107
|
+
const scratch = mkdtempSync(join(tmpdir(), "tmp."));
|
|
108
|
+
try {
|
|
109
|
+
const command = `rm -rf ${scratch} /tmp/tmp.8phya1BpFr /tmp/ctx-backstop-pnpm-test.log; cd /tmp && git log --oneline -1`;
|
|
110
|
+
for (const primary of [false, true]) {
|
|
111
|
+
const result = await harness(true, primary).handlers[0](event(command), context(async () => { throw new Error("child must never prompt"); }));
|
|
112
|
+
assert.equal((result as { block?: boolean })?.block, true, `child via ${primary ? "primary" : "safety"} entry`);
|
|
113
|
+
}
|
|
114
|
+
const titles: string[] = [];
|
|
115
|
+
const normal = harness(false, true);
|
|
116
|
+
assert.equal(await normal.handlers[0](event(command), context(async (title) => { titles.push(title); return true; })), undefined);
|
|
117
|
+
assert.deepEqual(titles, ["Allow recognized data-loss command?"]);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
84
123
|
test("children block recognized destructive commands sent to bash_background too", async () => {
|
|
85
124
|
const h = harness(true);
|
|
86
125
|
const result = await h.handlers[0]({ toolName: "bash_background", input: { command: "echo ok && rm -rf ./data" } }, context(async () => { throw new Error("child must never prompt"); }));
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import codeGraphTools, {
|
|
@@ -356,3 +356,124 @@ test("Windows launcher passes command metacharacters as one literal argv value",
|
|
|
356
356
|
}]);
|
|
357
357
|
assert.equal(existsSync(join(cwd, "shell-injection.txt")), false);
|
|
358
358
|
});
|
|
359
|
+
|
|
360
|
+
// Real CodeGraph writes .codegraph/.gitignore with `*` and `!.gitignore`, so the
|
|
361
|
+
// .gitignore itself stays untracked and would otherwise enter every diff.
|
|
362
|
+
const writeIndex: CodeGraphRunner = async (_args, options) => {
|
|
363
|
+
mkdirSync(join(options.cwd, ".codegraph"), { recursive: true });
|
|
364
|
+
writeFileSync(join(options.cwd, ".codegraph", ".gitignore"), "*\n!.gitignore\n");
|
|
365
|
+
writeFileSync(join(options.cwd, ".codegraph", "codegraph.db"), "index");
|
|
366
|
+
return { stdout: "indexed", stderr: "" };
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
function gitOutput(cwd: string, ...args: string[]): string {
|
|
370
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function excludeFile(cwd: string): string {
|
|
374
|
+
return realpathSync(resolve(cwd, gitOutput(cwd, "rev-parse", "--git-path", "info/exclude")));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function excludeLines(cwd: string): string[] {
|
|
378
|
+
return readFileSync(excludeFile(cwd), "utf8").split("\n").filter((line) => line.trim() === ".codegraph/");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
test("CodeGraph init keeps the whole .codegraph index out of git through the local exclude file, idempotently", async (t) => {
|
|
382
|
+
const cwd = workspace(t);
|
|
383
|
+
writeFileSync(join(cwd, "tracked.txt"), "tracked\n");
|
|
384
|
+
execFileSync("git", ["add", "tracked.txt"], { cwd });
|
|
385
|
+
const tool = createCodeGraphTool(writeIndex);
|
|
386
|
+
const ctx = { cwd } as ExtensionContext;
|
|
387
|
+
|
|
388
|
+
const first = await tool.execute("test", { operation: "init" }, undefined, undefined, ctx);
|
|
389
|
+
const second = await tool.execute("test", { operation: "init" }, undefined, undefined, ctx);
|
|
390
|
+
|
|
391
|
+
assert.deepEqual(first.content, [{ type: "text", text: "indexed" }]);
|
|
392
|
+
assert.equal(gitOutput(cwd, "status", "--porcelain", "--untracked-files=all"), "A tracked.txt");
|
|
393
|
+
assert.deepEqual(excludeLines(cwd), [".codegraph/"], "exactly one exclude line after repeated init");
|
|
394
|
+
assert.equal(existsSync(join(cwd, ".gitignore")), false, "never creates or edits a tracked .gitignore");
|
|
395
|
+
assert.deepEqual((first.details as { localExclude?: unknown }).localExclude, { status: "added", path: excludeFile(cwd) });
|
|
396
|
+
assert.deepEqual((second.details as { localExclude?: unknown }).localExclude, { status: "present", path: excludeFile(cwd) });
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("CodeGraph exclusion appends on a new line and recognizes an equivalent existing pattern", async (t) => {
|
|
400
|
+
const appended = workspace(t);
|
|
401
|
+
writeFileSync(excludeFile(appended), "# local\n*.log");
|
|
402
|
+
await createCodeGraphTool(writeIndex).execute("test", { operation: "init" }, undefined, undefined, { cwd: appended } as ExtensionContext);
|
|
403
|
+
assert.equal(readFileSync(excludeFile(appended), "utf8"), "# local\n*.log\n.codegraph/\n");
|
|
404
|
+
|
|
405
|
+
const equivalent = workspace(t);
|
|
406
|
+
writeFileSync(excludeFile(equivalent), "/.codegraph\n");
|
|
407
|
+
const result = await createCodeGraphTool(writeIndex).execute("test", { operation: "init" }, undefined, undefined, { cwd: equivalent } as ExtensionContext);
|
|
408
|
+
assert.equal(readFileSync(excludeFile(equivalent), "utf8"), "/.codegraph\n");
|
|
409
|
+
assert.equal((result.details as { localExclude?: { status: string } }).localExclude?.status, "present");
|
|
410
|
+
assert.equal(gitOutput(equivalent, "status", "--porcelain", "--untracked-files=all"), "");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("CodeGraph exclusion uses the exclude file git resolves for a linked worktree", async (t) => {
|
|
414
|
+
const base = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-worktree-")));
|
|
415
|
+
t.after(() => rmSync(base, { recursive: true, force: true }));
|
|
416
|
+
const primary = join(base, "primary");
|
|
417
|
+
const linked = join(base, "linked");
|
|
418
|
+
mkdirSync(primary);
|
|
419
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: primary, stdio: "ignore" });
|
|
420
|
+
execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.invalid", "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "fixture"], { cwd: primary, stdio: "ignore" });
|
|
421
|
+
execFileSync("git", ["worktree", "add", "-b", "feature", linked], { cwd: primary, stdio: "ignore" });
|
|
422
|
+
|
|
423
|
+
const result = await createCodeGraphTool(writeIndex).execute("test", { operation: "init" }, undefined, undefined, { cwd: linked } as ExtensionContext);
|
|
424
|
+
|
|
425
|
+
assert.deepEqual(result.content, [{ type: "text", text: "indexed" }]);
|
|
426
|
+
assert.equal(gitOutput(linked, "status", "--porcelain", "--untracked-files=all"), "");
|
|
427
|
+
assert.deepEqual(excludeLines(linked), [".codegraph/"]);
|
|
428
|
+
assert.equal((result.details as { localExclude?: { path: string } }).localExclude?.path, excludeFile(linked));
|
|
429
|
+
assert.equal(existsSync(join(linked, ".git", "info")), false, "the linked worktree's .git file is never treated as a directory");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("CodeGraph query excludes an index that already exists, and leaves the exclude file alone without one", async (t) => {
|
|
433
|
+
const indexed = workspace(t);
|
|
434
|
+
await writeIndex(["init", indexed], { cwd: indexed, maxBuffer: 1 });
|
|
435
|
+
const answer: CodeGraphRunner = async () => ({ stdout: "found", stderr: "" });
|
|
436
|
+
await createCodeGraphTool(answer).execute("test", { operation: "query", query: "symbol" }, undefined, undefined, { cwd: indexed } as ExtensionContext);
|
|
437
|
+
assert.deepEqual(excludeLines(indexed), [".codegraph/"]);
|
|
438
|
+
assert.equal(gitOutput(indexed, "status", "--porcelain", "--untracked-files=all"), "");
|
|
439
|
+
|
|
440
|
+
const bare = workspace(t);
|
|
441
|
+
const result = await createCodeGraphTool(answer).execute("test", { operation: "query", query: "symbol" }, undefined, undefined, { cwd: bare } as ExtensionContext);
|
|
442
|
+
assert.deepEqual(excludeLines(bare), []);
|
|
443
|
+
assert.equal((result.details as { localExclude?: unknown }).localExclude, undefined);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("CodeGraph init still succeeds and reports when git is unavailable for the local exclusion", async (t) => {
|
|
447
|
+
const cwd = workspace(t);
|
|
448
|
+
const empty = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-codegraph-nogit-")));
|
|
449
|
+
const originalPath = process.env.PATH;
|
|
450
|
+
t.after(() => {
|
|
451
|
+
process.env.PATH = originalPath;
|
|
452
|
+
rmSync(empty, { recursive: true, force: true });
|
|
453
|
+
});
|
|
454
|
+
const result = await createCodeGraphTool(async (args, options) => {
|
|
455
|
+
const output = await writeIndex(args, options);
|
|
456
|
+
process.env.PATH = empty; // git disappears after the workspace check
|
|
457
|
+
return output;
|
|
458
|
+
}).execute("test", { operation: "init" }, undefined, undefined, { cwd } as ExtensionContext);
|
|
459
|
+
process.env.PATH = originalPath;
|
|
460
|
+
|
|
461
|
+
assert.match(result.content[0].text, /^indexed\n\n/);
|
|
462
|
+
assert.match(result.content[0].text, /\.codegraph\/ is not excluded from git locally/);
|
|
463
|
+
assert.match(result.content[0].text, /keep \.codegraph\/ out of the change/i);
|
|
464
|
+
assert.equal((result.details as { localExclude?: { status: string } }).localExclude?.status, "failed");
|
|
465
|
+
assert.deepEqual(excludeLines(cwd), []);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("CodeGraph excludes a partial index left behind by a failed init without changing the fallback text", async (t) => {
|
|
469
|
+
const cwd = workspace(t);
|
|
470
|
+
const result = await createCodeGraphTool(async (args, options) => {
|
|
471
|
+
await writeIndex(args, options);
|
|
472
|
+
throw new Error("CodeGraph exited with status 1");
|
|
473
|
+
}).execute("test", { operation: "init" }, undefined, undefined, { cwd } as ExtensionContext);
|
|
474
|
+
|
|
475
|
+
assert.deepEqual(result.content, [{ type: "text", text: "CodeGraph failed to run. Use read, grep, and find for this exploration." }]);
|
|
476
|
+
assert.equal((result.details as { status: string }).status, "failed");
|
|
477
|
+
assert.equal((result.details as { localExclude?: { status: string } }).localExclude?.status, "added");
|
|
478
|
+
assert.equal(gitOutput(cwd, "status", "--porcelain", "--untracked-files=all"), "");
|
|
479
|
+
});
|
|
@@ -4,6 +4,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
5
|
import { CommandPalette, commandsKey, rankPaletteGroups, type CommandPaletteGroup, type CommandPaletteItem, type CommandPaletteResult, type CommandPaletteTheme } from "../lib/command-palette.ts";
|
|
6
6
|
import { buildCommandPaletteGroups, COMMAND_PALETTE_CATALOG } from "../lib/command-palette-catalog.ts";
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import startupBanner from "../extensions/startup-banner.ts";
|
|
7
9
|
|
|
8
10
|
// Command palette: a pure, curated, grouped overlay component for
|
|
9
11
|
// `/gentle:commands` and its `alt+k` shortcut. These tests drive ranking,
|
|
@@ -370,10 +372,6 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
370
372
|
"gentle:animations",
|
|
371
373
|
"gentle:vim",
|
|
372
374
|
"gentle:telemetry",
|
|
373
|
-
"gentle:banner",
|
|
374
|
-
"gentle:banner-color",
|
|
375
|
-
"gentle:toggle-rose",
|
|
376
|
-
"gentle:toggle-text-logo",
|
|
377
375
|
"gentle:dev-binary",
|
|
378
376
|
]);
|
|
379
377
|
assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission"]);
|
|
@@ -382,6 +380,18 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
382
380
|
assert.deepEqual(byTitle("Skills"), ["skill-registry:refresh"]);
|
|
383
381
|
});
|
|
384
382
|
|
|
383
|
+
// #1664: banner settings live under Customization > Banner; the palette keeps
|
|
384
|
+
// one route while the slash commands stay registered for compatibility.
|
|
385
|
+
test("banner settings have one palette route and keep their slash commands", () => {
|
|
386
|
+
const banner = ["gentle:banner", "gentle:banner-color", "gentle:toggle-rose", "gentle:toggle-text-logo"];
|
|
387
|
+
const catalogCommands = COMMAND_PALETTE_CATALOG.flatMap((group) => group.items.map((item) => item.command));
|
|
388
|
+
for (const command of banner) assert.ok(!catalogCommands.includes(command), `${command} must not appear in the palette`);
|
|
389
|
+
assert.ok(catalogCommands.includes("gentle:customize"));
|
|
390
|
+
const registered: string[] = [];
|
|
391
|
+
startupBanner({ on() {}, registerCommand(name: string) { registered.push(name); }, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
|
|
392
|
+
for (const command of banner) assert.ok(registered.includes(command), `${command} stays registered`);
|
|
393
|
+
});
|
|
394
|
+
|
|
385
395
|
test("retired SDD commands are absent from the palette and extension registrations", () => {
|
|
386
396
|
const retired = ["gentle:sdd-preflight", "gentle-sdd-status", "gentle-sdd-continue", "gentle-sdd-init", "gentle:install-sdd"];
|
|
387
397
|
const catalogCommands = COMMAND_PALETTE_CATALOG.flatMap((group) => group.items.map((item) => item.command));
|