gentle-pi 4.2.0 → 4.4.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 +340 -138
- 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/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- 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 +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- 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/bundled-install.md +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- 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-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- 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/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- 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 +645 -21
- 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-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- 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 +79 -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 +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- 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/session-changes-shell.test.ts +1 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- 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 +70 -10
- 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 +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
- 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
|
@@ -33,6 +33,7 @@ interface ChoiceTool {
|
|
|
33
33
|
promptGuidelines?: string[];
|
|
34
34
|
parameters: ChoiceParameters;
|
|
35
35
|
execute: (...args: unknown[]) => Promise<ChoiceResult>;
|
|
36
|
+
renderResult?: (result: unknown, options: unknown, theme: ChoiceTheme) => { render(width: number): string[] };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
interface ChoiceLifecycleEvent {
|
|
@@ -711,3 +712,32 @@ test("ask_user_choice is offered on an interactive rpc host and withdrawn once t
|
|
|
711
712
|
for (const hook of registration.hooks) await hook({}, { mode: "rpc" });
|
|
712
713
|
assert.deepEqual(registration.activeTools(), ["read"]);
|
|
713
714
|
});
|
|
715
|
+
|
|
716
|
+
test("ask_user_choice renderResult renders selected, custom response with text, and cancelled states (#1692)", () => {
|
|
717
|
+
const registration = registerChoiceTool();
|
|
718
|
+
const theme: ChoiceTheme = { fg: (_color, text) => text, bg: (_color, text) => text, bold: (text) => text };
|
|
719
|
+
|
|
720
|
+
// 1. Selected option
|
|
721
|
+
const selected = registration.tool.renderResult!(
|
|
722
|
+
{ details: { selection: { index: 1, label: "Deploy to production" } } },
|
|
723
|
+
{},
|
|
724
|
+
theme,
|
|
725
|
+
).render(80).join("\n");
|
|
726
|
+
assert.match(selected, /✓ 1\. Deploy to production/);
|
|
727
|
+
|
|
728
|
+
// 2. Custom response with submitted text (#1692)
|
|
729
|
+
const custom = registration.tool.renderResult!(
|
|
730
|
+
{ details: { customResponse: "use the staging bucket" } },
|
|
731
|
+
{},
|
|
732
|
+
theme,
|
|
733
|
+
).render(80).join("\n");
|
|
734
|
+
assert.match(custom, /✓ Custom response — use the staging bucket/);
|
|
735
|
+
|
|
736
|
+
// 3. Cancelled
|
|
737
|
+
const cancelled = registration.tool.renderResult!(
|
|
738
|
+
{ details: { cancelled: true } },
|
|
739
|
+
{},
|
|
740
|
+
theme,
|
|
741
|
+
).render(80).join("\n");
|
|
742
|
+
assert.match(cancelled, /Cancelled/);
|
|
743
|
+
});
|
package/tests/auto-mode.test.ts
CHANGED
|
@@ -42,6 +42,23 @@ test("Auto cannot bypass data-loss, configured confirmation, protected paths or
|
|
|
42
42
|
assert.equal(h.requests.length,0);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
test("production hook classifies companion updates without granting permission to change authority",async t=>{
|
|
46
|
+
const h=harness(t);await h.enable();await h.input("Update the installed companion tools from main; preserve permissions.");
|
|
47
|
+
for(const toolName of ["write","edit"]) {
|
|
48
|
+
assert.equal(await h.call(toolName,{path:join(h.home,"dev-binary.json"),content:"fixture companion update"}),undefined);
|
|
49
|
+
}
|
|
50
|
+
assert.equal(h.requests.length,2,"updates reach the ordinary Auto classifier");
|
|
51
|
+
h.setDecision("deny");
|
|
52
|
+
assert.equal((await h.call("write",{path:join(h.home,"channel.json"),content:"unapproved"})).block,true);
|
|
53
|
+
assert.equal(h.requests.length,3);
|
|
54
|
+
h.setDecision("allow");
|
|
55
|
+
for(const name of ["permission-mode.json","runtime-guardrails.json","models.json","persona.json"]) {
|
|
56
|
+
assert.equal((await h.call("write",{path:join(h.home,name),content:"{}"})).block,true);
|
|
57
|
+
}
|
|
58
|
+
assert.equal(h.requests.length,3,"a classifier allow cannot override protected authority");
|
|
59
|
+
assert.equal(h.confirmations(),0);
|
|
60
|
+
});
|
|
61
|
+
|
|
45
62
|
test("normal mode preserves previous guard behavior and named children do not inherit Auto",async t=>{
|
|
46
63
|
const h=harness(t);assert.equal((await h.call("bash",{command:"git push origin feature"})).block,true);assert.equal(h.requests.length,0);
|
|
47
64
|
await h.enable();await h.emit("before_agent_start",{agent:{name:"worker"},systemPromptOptions:{}});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { AutoPermissionMemory } from "../lib/auto-permission-memory.ts";
|
|
4
|
+
test("binder diagnostics retain rejection cause without destination or content",()=>{
|
|
5
|
+
const memory=new AutoPermissionMemory(()=>[]);
|
|
6
|
+
const ctx:any={cwd:"PRIVATE_CWD",sessionManager:{getSessionId:()=>"PRIVATE_SESSION",getBranch:()=>[{id:"PRIVATE_ANCHOR"}]}};
|
|
7
|
+
memory.admitted({toolName:"mem_current_project",toolCallId:"PRIVATE_CALL",input:{cwd:ctx.cwd}},ctx,1);
|
|
8
|
+
memory.context({toolName:"mem_save",input:{cwd:ctx.cwd,project:"PRIVATE_PROJECT",content:"PRIVATE_CONTENT"}},ctx,1);
|
|
9
|
+
assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"untrusted-source",sourceRejection:"tool-missing"});
|
|
10
|
+
assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
|
|
11
|
+
memory.clear();
|
|
12
|
+
memory.context({toolName:"mem_session_summary",input:{}},ctx,1);
|
|
13
|
+
assert.deepEqual(memory.diagnostic(),{stage:"clear",reason:"binding-cleared"});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("source rejection reports only the first failed provenance predicate",()=>{
|
|
17
|
+
const sourceInfo={path:"/installed/engram.ts",source:"npm:gentle-engram@latest",scope:"user",origin:"package"};
|
|
18
|
+
const tool={name:"mem_current_project",sourceInfo};
|
|
19
|
+
const cases:[string,any[]][]=[
|
|
20
|
+
["tool-missing",[]],["tool-ambiguous",[tool,tool]],
|
|
21
|
+
["metadata-missing",[{name:tool.name}]],
|
|
22
|
+
["origin",[{...tool,sourceInfo:{...sourceInfo,origin:"top-level",source:"local"}}]],
|
|
23
|
+
["source-format",[{...tool,sourceInfo:{...sourceInfo,source:"PRIVATE_SOURCE"}}]],
|
|
24
|
+
["path",[{...tool,sourceInfo:{...sourceInfo,path:"<PRIVATE_PATH>"}}]],
|
|
25
|
+
];
|
|
26
|
+
const ctx:any={cwd:"/project",sessionManager:{getSessionId:()=>"session",getBranch:()=>[{id:"human"}]}};
|
|
27
|
+
const call={toolName:tool.name,toolCallId:"resolve",input:{cwd:ctx.cwd}};
|
|
28
|
+
for(const [sourceRejection,tools] of cases){
|
|
29
|
+
const memory=new AutoPermissionMemory(()=>tools);
|
|
30
|
+
memory.admitted(call,ctx,1);
|
|
31
|
+
assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"untrusted-source",sourceRejection});
|
|
32
|
+
memory.context({toolName:"mem_save",input:{}},ctx,1);
|
|
33
|
+
assert.equal(memory.diagnostic()?.sourceRejection,sourceRejection);
|
|
34
|
+
assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
|
|
35
|
+
}
|
|
36
|
+
const memory=new AutoPermissionMemory(()=>[tool] as any);
|
|
37
|
+
memory.admitted(call,ctx,1);
|
|
38
|
+
assert.deepEqual(memory.diagnostic(),{stage:"admission",reason:"admitted"});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("registry exceptions replace successful attachment diagnostics",()=>{
|
|
42
|
+
let fail=false;
|
|
43
|
+
const sourceInfo={path:"/installed/engram.ts",source:"npm:gentle-engram",scope:"user",origin:"package"};
|
|
44
|
+
const memory=new AutoPermissionMemory(()=>{if(fail)throw Error("PRIVATE_ERROR");return ["mem_current_project","mem_save"].map(name=>({name,sourceInfo})) as any;});
|
|
45
|
+
const ctx:any={cwd:"/project",sessionManager:{getSessionId:()=>"session",getBranch:()=>[{id:"human"}]}};
|
|
46
|
+
const call={toolName:"mem_current_project",toolCallId:"resolve",input:{cwd:ctx.cwd}};
|
|
47
|
+
memory.admitted(call,ctx,1);
|
|
48
|
+
memory.result({...call,isError:false,details:{data:{cwd:ctx.cwd,project:"project",project_source:"git_remote"}}} as any,ctx,1);
|
|
49
|
+
const save={toolName:"mem_save",input:{cwd:ctx.cwd,project:"project"}};
|
|
50
|
+
assert.ok(memory.context(save,ctx,1));
|
|
51
|
+
assert.deepEqual(memory.diagnostic(),{stage:"write",reason:"attached"});
|
|
52
|
+
fail=true;
|
|
53
|
+
assert.equal(memory.context(save,ctx,1),undefined);
|
|
54
|
+
assert.deepEqual(memory.diagnostic(),{stage:"write",reason:"unexpected-error"});
|
|
55
|
+
assert.doesNotMatch(JSON.stringify(memory.diagnostic()),/PRIVATE/);
|
|
56
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, mkdirSync, symlinkSync, linkSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { AutoPermissionRuntime } from "../lib/auto-permission-runtime.ts";
|
|
@@ -258,6 +258,90 @@ test("Native consent, human questions and delegation remain with their original
|
|
|
258
258
|
f.setMode("auto");assert.equal((await f.execute("custom_tool",{},false)).executions,1);assert.equal(f.requests.length,0);
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
test("Companion update files are not permission authority and still receive ordinary checks", async t=>{
|
|
262
|
+
const f=fixture(t);f.input("Update the installed companion tools from main.");
|
|
263
|
+
for(const mode of ["normal","auto","yolo"]) {
|
|
264
|
+
f.setMode(mode);
|
|
265
|
+
for(const path of ["dev-binary.json","channel.json","main/gentle-ai","tools/go/bin/go"]) {
|
|
266
|
+
const input={path:join(f.cwd,"config",path),content:"updated companion data"};
|
|
267
|
+
for(const toolName of ["write","edit"]) {
|
|
268
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx),undefined,`${mode}: ${path} is not policy`);
|
|
269
|
+
assert.equal((await f.execute(toolName,input)).executions,1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
assert.equal(f.requests.length,8,"only Auto requests are classified; other modes keep their own behavior");
|
|
274
|
+
f.setMode("auto");f.setResponse({status:"available",decision:"deny",reason:"Update not authorized"});
|
|
275
|
+
assert.equal((await f.execute("write",{path:join(f.cwd,"config","dev-binary.json"),content:"unapproved"})).executions,0);
|
|
276
|
+
assert.equal(f.requests.length,9,"an update path is not an automatic allow");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("Permission authority, aliases and parent directories remain protected in every mode", async t=>{
|
|
280
|
+
const f=fixture(t);f.input("Update companion tools without changing permissions.");
|
|
281
|
+
const home=join(f.cwd,"config");mkdirSync(home);
|
|
282
|
+
const projectHome=join(f.cwd,".pi","gentle-ai");mkdirSync(projectHome,{recursive:true});
|
|
283
|
+
for(const name of ["permission-mode.json","runtime-guardrails.json"])writeFileSync(join(home,name),"{}");
|
|
284
|
+
writeFileSync(join(projectHome,"runtime-guardrails.json"),"{}");
|
|
285
|
+
symlinkSync(home,join(f.cwd,"config-alias"));
|
|
286
|
+
symlinkSync(join(home,"permission-mode.json"),join(f.cwd,"policy-alias"));
|
|
287
|
+
symlinkSync(join(home,"runtime-guardrails.json"),join(home,"dev-binary.json"));
|
|
288
|
+
for(const mode of ["normal","auto","yolo"]) {
|
|
289
|
+
f.setMode(mode);
|
|
290
|
+
for(const path of [join(home,"permission-mode.json"),join(home,"runtime-guardrails.json"),
|
|
291
|
+
join(f.cwd,"config-alias","permission-mode.json"),join(f.cwd,"policy-alias"),join(home,"dev-binary.json"),
|
|
292
|
+
join(projectHome,"runtime-guardrails.json"),projectHome,home,f.cwd,
|
|
293
|
+
...["profiles.json","models.json","background-subagents.json","persona.json","builtin-codemode-optout.json",
|
|
294
|
+
"history-capture.json","scratch/session.json","tools/other-tool"].map(path=>join(home,path))]) {
|
|
295
|
+
for(const toolName of ["write","edit"]) {
|
|
296
|
+
const input={path,content:"{}"};
|
|
297
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${path} stays protected`);
|
|
298
|
+
assert.equal((await f.execute(toolName,input)).executions,0);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for(const toolName of ["bash","bash_background","monitor"]) {
|
|
302
|
+
for(const command of [`rm -rf '${home}'`,`rm -rf '${home}/'`,`rm -rf '${home}'/*`,
|
|
303
|
+
`printf '{}' > '${join(home,"permission-mode.json")}'`,`printf '{}' > '${join(home,"runtime-guardrails.json")}'`,
|
|
304
|
+
`printf '{}' > '${join(projectHome,"runtime-guardrails.json")}'`]) {
|
|
305
|
+
assert.equal(f.runtime.guard({toolName,input:{command}},f.ctx)?.block,true);
|
|
306
|
+
assert.equal((await f.execute(toolName,{command})).executions,0);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
assert.equal(f.requests.length,0,"a classifier allow never overrides the policy guard");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("Updater exceptions cannot overwrite authority through hardlinks", async t=>{
|
|
314
|
+
const f=fixture(t);f.input("Update companion tools without changing permission or routing policy.");
|
|
315
|
+
const home=join(f.cwd,"config");mkdirSync(join(home,"main"),{recursive:true});
|
|
316
|
+
for(const [policy,alias] of [["permission-mode.json","dev-binary.json"],["models.json","main/routing-alias"]]) {
|
|
317
|
+
writeFileSync(join(home,policy),"{}");linkSync(join(home,policy),join(home,alias));
|
|
318
|
+
for(const toolName of ["write","edit"]) {
|
|
319
|
+
const input={path:join(home,alias),content:"{}"};
|
|
320
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,alias);
|
|
321
|
+
assert.equal((await f.execute(toolName,input)).executions,0);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
assert.equal(f.requests.length,0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("Dangling updater aliases cannot create absent permission authority", async t=>{
|
|
328
|
+
const f=fixture(t);f.input("Update companion tools without creating or changing permission policy.");
|
|
329
|
+
const home=join(f.cwd,"config");mkdirSync(join(home,"tools"),{recursive:true});
|
|
330
|
+
for(const [alias,policy] of [["dev-binary.json","permission-mode.json"],["channel.json","runtime-guardrails.json"],
|
|
331
|
+
["main","permission-mode.json"],["tools/go","runtime-guardrails.json"]]) {
|
|
332
|
+
symlinkSync(join(home,policy),join(home,alias));
|
|
333
|
+
for(const mode of ["normal","auto","yolo"]) {
|
|
334
|
+
f.setMode(mode);
|
|
335
|
+
for(const toolName of ["write","edit"]) {
|
|
336
|
+
const input={path:join(home,alias),content:"{}"};
|
|
337
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${alias} stays protected`);
|
|
338
|
+
assert.equal((await f.execute(toolName,input)).executions,0);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
assert.equal(f.requests.length,0,"unresolved aliases never reach an allowing classifier");
|
|
343
|
+
});
|
|
344
|
+
|
|
261
345
|
test("Policy files cannot be modified by model file tools; symlink escapes are reviewed", async t=>{
|
|
262
346
|
const f=fixture(t);f.input("Edit source code");mkdirSync(join(f.cwd,"config"));
|
|
263
347
|
assert.equal((await f.execute("write",{path:join(f.cwd,"config","permission-mode.json"),content:"yolo"})).executions,0);
|
|
@@ -40,6 +40,84 @@ test("YOLO waives reversible push and rebase segments after full evaluation (#21
|
|
|
40
40
|
]) assert.equal(isReversibleYoloCommand(command, evaluateGuardedCommand(command, config)), false, command);
|
|
41
41
|
});
|
|
42
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
|
+
|
|
43
121
|
// gentle-shell#2174: a guarded word inside an argument of a known data command
|
|
44
122
|
// (search, print, view, non-executing edit) is text, not an operation. Every
|
|
45
123
|
// opaque form keeps today's lexical match.
|
|
@@ -53,6 +131,11 @@ test("guarded words inside data-command arguments are not guarded operations (#2
|
|
|
53
131
|
"cat > probe.mts <<'EOF'\nconst x = \"git push --force origin main\";\nEOF",
|
|
54
132
|
"grep -rn 'git reset --hard' docs",
|
|
55
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",
|
|
56
139
|
]) assert.equal(classifyGuardedCommand(command, config), "not-guarded", command);
|
|
57
140
|
for (const [command, expected] of [
|
|
58
141
|
["git push origin main", "confirm"],
|
|
@@ -61,6 +144,15 @@ test("guarded words inside data-command arguments are not guarded operations (#2
|
|
|
61
144
|
["bash <<'EOF'\ngit push origin main\nEOF", "confirm"],
|
|
62
145
|
["python3 -c \"import os; os.system('git push')\"", "confirm"],
|
|
63
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"],
|
|
64
156
|
["grep 'git push origin main' f | sh", "confirm"],
|
|
65
157
|
["time git push origin main", "confirm"],
|
|
66
158
|
["sed 's/x/git push/e' file", "confirm"],
|
|
@@ -145,6 +237,36 @@ test("quoted separator arguments cannot waive hard denial or invent destruction"
|
|
|
145
237
|
}
|
|
146
238
|
});
|
|
147
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
|
+
|
|
148
270
|
// ---------------------------------------------------------------------------
|
|
149
271
|
// Helpers
|
|
150
272
|
// ---------------------------------------------------------------------------
|
|
@@ -788,3 +910,23 @@ test("loadRuntimeGuardrailsConfig: autonomousMode:{} (object) in JSON does NOT a
|
|
|
788
910
|
rmSync(dir, { recursive: true, force: true });
|
|
789
911
|
}
|
|
790
912
|
});
|
|
913
|
+
|
|
914
|
+
test("evaluateSuperYoloCommand: waives data-loss and configured confirmations, keeps only catastrophic denies", () => {
|
|
915
|
+
const { evaluateSuperYoloCommand } = __testing;
|
|
916
|
+
// Ordinary data-loss commands run without confirmation.
|
|
917
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf node_modules build"), "allow");
|
|
918
|
+
assert.equal(evaluateSuperYoloCommand("git rebase origin/main"), "allow");
|
|
919
|
+
assert.equal(evaluateSuperYoloCommand("git branch -D feature/x"), "allow");
|
|
920
|
+
assert.equal(evaluateSuperYoloCommand("npm publish"), "allow");
|
|
921
|
+
assert.equal(evaluateSuperYoloCommand("git push -u origin feature"), "allow");
|
|
922
|
+
// Code-level catastrophic denies survive: root/home removal and hard-coded
|
|
923
|
+
// destructive Git forms (reset --hard, forced clean, force push).
|
|
924
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf /"), "block");
|
|
925
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf ~"), "block");
|
|
926
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf --no-preserve-root /"), "block");
|
|
927
|
+
assert.equal(evaluateSuperYoloCommand("git reset --hard HEAD~1"), "block");
|
|
928
|
+
assert.equal(evaluateSuperYoloCommand("git push --force origin main"), "block");
|
|
929
|
+
assert.equal(evaluateSuperYoloCommand("git clean -fd"), "block");
|
|
930
|
+
// Harmless commands are unaffected.
|
|
931
|
+
assert.equal(evaluateSuperYoloCommand("git status && npm test"), "allow");
|
|
932
|
+
});
|
|
@@ -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.5.2";
|
|
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.5.2/engram_3.5.2_linux_amd64.tar.gz" };
|
|
377
|
+
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_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"); }));
|