@pi-archimedes/sudo 2.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/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +36 -0
- package/src/argv-split.test.ts +59 -0
- package/src/argv-split.ts +82 -0
- package/src/cache.test.ts +91 -0
- package/src/cache.ts +58 -0
- package/src/config.test.ts +47 -0
- package/src/config.ts +27 -0
- package/src/guard.test.ts +427 -0
- package/src/guard.ts +759 -0
- package/src/index.ts +81 -0
- package/src/lifecycle.test.ts +165 -0
- package/src/prompt.test.ts +149 -0
- package/src/prompt.ts +99 -0
- package/src/tool.test.ts +1364 -0
- package/src/tool.ts +587 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Cherubini and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @pi-archimedes/sudo
|
|
2
|
+
|
|
3
|
+
Safe privileged execution for the [Pi coding agent](https://github.com/earendil-works/pi): a dedicated `sudo_exec` tool with a masked password prompt, plus a guard that keeps the ordinary `bash` tool from driving interactive `sudo`.
|
|
4
|
+
|
|
5
|
+
## What you get
|
|
6
|
+
|
|
7
|
+
- **`sudo_exec` tool** — runs a privileged command via `sudo -S`, showing the exact command and reason for confirmation before any credential is requested; on timeout/abort the kill applies to the command's entire process group, so privileged (root) descendants are killed too, not just the direct sudo process — except a command that intentionally detaches itself into its own session (`setsid`/daemonizing), which leaves the group by definition and is beyond any user-space kill (the same reach as `tmux kill-pane`): give such commands a managed lifecycle flag (e.g. `--foreground`) instead
|
|
8
|
+
- **Masked password prompt** — the password is entered only through a masked UI, cached in memory for the session, and passed to sudo via stdin only — never in argv, env, logs, or files
|
|
9
|
+
- **Defensive scrubbing** — command output lines containing the password are redacted before they appear in tool results
|
|
10
|
+
- **Credential lifecycle** — single in-memory cache with TTL (default 15 min, `ttlMs`); cleared on auth failure, `session_start`/`session_shutdown`, and `/sudo forget`
|
|
11
|
+
- **Headless sessions blocked** — subagent/headless sessions get a clear error from `sudo_exec` instead of a prompt; the masked prompt only ever appears in a human's TUI
|
|
12
|
+
- **Bash guard** — active `tool_call` veto (ADR 0010): interactive `sudo` through the built-in `bash` tool is blocked, funneling privileged execution through `sudo_exec`
|
|
13
|
+
- **`/sudo` + `/sudo forget`** — report the credential-cache state or clear it from memory
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@pi-archimedes/sudo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
### The `sudo_exec` tool
|
|
24
|
+
|
|
25
|
+
```jsonc
|
|
26
|
+
{
|
|
27
|
+
"command": "apt install ripgrep", // exact argv string — no leading 'sudo'; no shell syntax (pipes, &&, redirects, env assignments)
|
|
28
|
+
"reason": "ripgrep is needed for the search tooling", // required — shown to the user before execution
|
|
29
|
+
"timeoutMs": 120000 // optional override of config.defaultTimeoutMs
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The tool uses pi's built-in `renderCall`/`renderResult`; command output is surfaced as plain text, scrubbed with the password masked.
|
|
34
|
+
|
|
35
|
+
### The bash guard
|
|
36
|
+
|
|
37
|
+
A pure, exhaustively-tested scanner vetoes `tool_call` events on the built-in `bash` tool:
|
|
38
|
+
|
|
39
|
+
- **Blocked:** `sudo` in command position without a no-prompt flag — including through runner wrappers (`env`, `nohup`, `timeout`, `xargs`, …), nested shells (`bash -c`, `su -c`), `eval`, compound keywords, and heredoc bodies
|
|
40
|
+
- **Allowed:** non-interactive sudo (`sudo -n`, `-l`, `-v`, `-K`, `-k`, `--non-interactive`) — these cannot prompt and pass through untouched
|
|
41
|
+
|
|
42
|
+
The guard is a heuristic with accepted residual bypasses documented in the [ADR 0010 design notes](../../docs/adr/0010-archimedes-sudo-security.md) (e.g. cross-token variable indirection, and sudo inside `$(...)`/backtick interpolation whose text the word-position model cannot see). Over-blocking is the safe direction; the tested no-prompt flag set is a stable contract.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
- `/sudo` — report whether a credential is cached
|
|
47
|
+
- `/sudo forget` — clear the credential from memory
|
|
48
|
+
|
|
49
|
+
## Settings
|
|
50
|
+
|
|
51
|
+
| Setting | Type | Default | Description |
|
|
52
|
+
|---------|------|---------|-------------|
|
|
53
|
+
| `ttlMs` | number | `900000` | Password cache TTL in milliseconds (default 15 minutes) |
|
|
54
|
+
| `defaultTimeoutMs` | number | `120000` | `sudo_exec` default command timeout in milliseconds (default 120 seconds) |
|
|
55
|
+
|
|
56
|
+
On/off is managed by the suite: toggle via `/plugins` (`archimedes.sudo.enabled`, default on). Config is JSON-only in the `archimedes.sudo` namespace of `~/.pi/agent/settings.json` — there is no settings-panel UI in v1.
|
|
57
|
+
|
|
58
|
+
### Credential limitation on sudoers that retain no reusable ticket
|
|
59
|
+
|
|
60
|
+
On sudoers policies that retain no reusable credential ticket (e.g. `timestamp_timeout=0` plus strict `Defaults`), an authenticated **command** failure is indistinguishable from an authentication failure to any non-interactive check — so the tool uses a two-consecutive-failure rule: the first failure keeps the cached password (with a visible warning), the second clears it. A wrong password on such a sudo is therefore detected on the second failure rather than the first — the bounded cost of a policy that exposes no ticket to verify against.
|
|
61
|
+
|
|
62
|
+
## Integration
|
|
63
|
+
|
|
64
|
+
When installed via `pi-archimedes` (the meta package), the sudo package is registered and gated by the suite's plugin manifest (ADR 0012); the bash guard and `sudo_exec` are loaded in both the main session and subagent children — the guard still vetoes there, while `sudo_exec` itself refuses to run headless. Standalone installs work independently.
|
|
65
|
+
|
|
66
|
+
← Back to [pi-archimedes](../../README.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-archimedes/sudo",
|
|
3
|
+
"version": "2.4.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"description": "Safe privileged execution for pi — sudo_exec tool with masked password prompt and bash-sudo guard",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./guard": "./src/guard.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@pi-archimedes/core": "2.4.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
22
|
+
"@earendil-works/pi-tui": ">=0.1.0",
|
|
23
|
+
"typebox": ">=1.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
27
|
+
"@earendil-works/pi-tui": "^0.84.2",
|
|
28
|
+
"typebox": "^1.1.38",
|
|
29
|
+
"typescript": "^6.0.0"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./src/index.ts"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { splitCommandIntoArgv } from "./argv-split.js";
|
|
3
|
+
|
|
4
|
+
describe("splitCommandIntoArgv", () => {
|
|
5
|
+
it("splits simple whitespace-separated words", () => {
|
|
6
|
+
expect(splitCommandIntoArgv("apt install ripgrep")).toEqual(["apt", "install", "ripgrep"]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("collapses runs of spaces and trims leading/trailing whitespace", () => {
|
|
10
|
+
expect(splitCommandIntoArgv(" ls -la ")).toEqual(["ls", "-la"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("keeps single-quoted strings together, stripping the quotes", () => {
|
|
14
|
+
expect(splitCommandIntoArgv("echo 'hello world'")).toEqual(["echo", "hello world"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("keeps double-quoted strings together, stripping the quotes", () => {
|
|
18
|
+
expect(splitCommandIntoArgv('echo "hi there"')).toEqual(["echo", "hi there"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("honours backslash escapes outside quotes", () => {
|
|
22
|
+
expect(splitCommandIntoArgv("echo a\\ b")).toEqual(["echo", "a b"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("honours backslash-escaped quote characters inside double quotes", () => {
|
|
26
|
+
expect(splitCommandIntoArgv('echo "a\\"b"')).toEqual(["echo", 'a"b']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("honours escaped backslashes inside double quotes", () => {
|
|
30
|
+
expect(splitCommandIntoArgv('echo "a\\\\"')).toEqual(["echo", "a\\"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("single quotes are fully literal (no backslash processing inside)", () => {
|
|
34
|
+
expect(splitCommandIntoArgv("echo 'a\\ b'")).toEqual(["echo", "a\\ b"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("produces an empty word for a quoted empty string", () => {
|
|
38
|
+
expect(splitCommandIntoArgv('echo ""')).toEqual(["echo", ""]);
|
|
39
|
+
expect(splitCommandIntoArgv("echo ''")).toEqual(["echo", ""]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("empty and whitespace-only input produce no words", () => {
|
|
43
|
+
expect(splitCommandIntoArgv("")).toEqual([]);
|
|
44
|
+
expect(splitCommandIntoArgv(" ")).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("adjacent quotes and bare text merge into one word", () => {
|
|
48
|
+
expect(splitCommandIntoArgv('echo "a b"flag')).toEqual(["echo", "a bflag"]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps shell metacharacters as their own words (no shell semantics)", () => {
|
|
52
|
+
expect(splitCommandIntoArgv("ls | grep foo")).toEqual(["ls", "|", "grep", "foo"]);
|
|
53
|
+
expect(splitCommandIntoArgv("echo a && echo b")).toEqual(["echo", "a", "&&", "echo", "b"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("handles a missing closing double quote by running to end of input", () => {
|
|
57
|
+
expect(splitCommandIntoArgv('echo "unterminated')).toEqual(["echo", "unterminated"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small POSIX-ish shell-word splitter for `sudo -S` argv construction.
|
|
3
|
+
*
|
|
4
|
+
* Handles single quotes (fully literal), double quotes (backslash escapes
|
|
5
|
+
* `\"` and `\\`), and backslash escapes outside quotes. It deliberately has
|
|
6
|
+
* NO other shell semantics: no pipes, redirects, globbing, `&&`, or env
|
|
7
|
+
* assignments are interpreted — those characters stay in the words, so a
|
|
8
|
+
* `command` param containing them passes through to sudo's argv rather than
|
|
9
|
+
* being executed by a shell. This is why the tool spawns a real argv and
|
|
10
|
+
* never interpolates into a shell string.
|
|
11
|
+
*/
|
|
12
|
+
export function splitCommandIntoArgv(command: string): string[] {
|
|
13
|
+
const args: string[] = [];
|
|
14
|
+
let current = "";
|
|
15
|
+
let hasWord = false;
|
|
16
|
+
let i = 0;
|
|
17
|
+
|
|
18
|
+
const appendWord = () => {
|
|
19
|
+
args.push(current);
|
|
20
|
+
current = "";
|
|
21
|
+
hasWord = false;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
while (i < command.length) {
|
|
25
|
+
const ch = command.charAt(i);
|
|
26
|
+
|
|
27
|
+
if (ch === "'") {
|
|
28
|
+
// Single quotes: literal until the closing quote.
|
|
29
|
+
i++;
|
|
30
|
+
let closed = false;
|
|
31
|
+
while (i < command.length) {
|
|
32
|
+
const inner = command.charAt(i);
|
|
33
|
+
if (inner === "'") {
|
|
34
|
+
closed = true;
|
|
35
|
+
i++;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
current += inner;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
hasWord = true;
|
|
42
|
+
if (!closed) break; // unterminated — rest was literal
|
|
43
|
+
} else if (ch === '"') {
|
|
44
|
+
// Double quotes: backslash only escapes " and \.
|
|
45
|
+
i++;
|
|
46
|
+
let closed = false;
|
|
47
|
+
while (i < command.length) {
|
|
48
|
+
const inner = command.charAt(i);
|
|
49
|
+
if (inner === "\\" && (command.charAt(i + 1) === '"' || command.charAt(i + 1) === "\\")) {
|
|
50
|
+
current += command.charAt(i + 1);
|
|
51
|
+
i += 2;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (inner === '"') {
|
|
55
|
+
closed = true;
|
|
56
|
+
i++;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
current += inner;
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
hasWord = true;
|
|
63
|
+
if (!closed) break; // unterminated — rest was literal
|
|
64
|
+
} else if (ch === "\\") {
|
|
65
|
+
// Outside quotes: escape the next character.
|
|
66
|
+
const next = command.charAt(i + 1);
|
|
67
|
+
current += next === undefined ? "\\" : next;
|
|
68
|
+
i += 2;
|
|
69
|
+
hasWord = true;
|
|
70
|
+
} else if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
|
|
71
|
+
if (hasWord) appendWord();
|
|
72
|
+
i++;
|
|
73
|
+
} else {
|
|
74
|
+
current += ch;
|
|
75
|
+
i++;
|
|
76
|
+
hasWord = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (hasWord) appendWord();
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { CredentialCache } from "./cache.js";
|
|
3
|
+
|
|
4
|
+
const TTL = 60_000;
|
|
5
|
+
|
|
6
|
+
describe("CredentialCache", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("is empty before any set: get() null, isExpired false", () => {
|
|
15
|
+
const cache = new CredentialCache();
|
|
16
|
+
expect(cache.get()).toBeNull();
|
|
17
|
+
expect(cache.isExpired).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns the credential while within the TTL", () => {
|
|
21
|
+
const cache = new CredentialCache();
|
|
22
|
+
cache.set("hunter2", TTL);
|
|
23
|
+
const got = cache.get();
|
|
24
|
+
expect(got).not.toBeNull();
|
|
25
|
+
expect(got?.password).toBe("hunter2");
|
|
26
|
+
expect(got?.expiresAt).toBe(Date.now() + TTL);
|
|
27
|
+
expect(cache.isExpired).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns null once the TTL has elapsed (does not auto-clear the entry)", () => {
|
|
31
|
+
const cache = new CredentialCache();
|
|
32
|
+
cache.set("hunter2", TTL);
|
|
33
|
+
vi.advanceTimersByTime(TTL + 1);
|
|
34
|
+
expect(cache.get()).toBeNull();
|
|
35
|
+
// still set, just stale — isExpired flags it until clear()
|
|
36
|
+
expect(cache.isExpired).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("isExpired is false exactly at set time and true once the deadline passes", () => {
|
|
40
|
+
const cache = new CredentialCache();
|
|
41
|
+
cache.set("hunter2", TTL);
|
|
42
|
+
expect(cache.isExpired).toBe(false);
|
|
43
|
+
vi.advanceTimersByTime(TTL);
|
|
44
|
+
expect(cache.isExpired).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("clear() empties the cache and resets isExpired", () => {
|
|
48
|
+
const cache = new CredentialCache();
|
|
49
|
+
cache.set("hunter2", TTL);
|
|
50
|
+
vi.advanceTimersByTime(TTL + 1);
|
|
51
|
+
expect(cache.isExpired).toBe(true);
|
|
52
|
+
cache.clear();
|
|
53
|
+
expect(cache.get()).toBeNull();
|
|
54
|
+
expect(cache.isExpired).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("set() replaces the previous entry with a fresh TTL", () => {
|
|
58
|
+
const cache = new CredentialCache();
|
|
59
|
+
cache.set("first", TTL);
|
|
60
|
+
vi.advanceTimersByTime(TTL - 1000);
|
|
61
|
+
cache.set("second", TTL);
|
|
62
|
+
const got = cache.get();
|
|
63
|
+
expect(got?.password).toBe("second");
|
|
64
|
+
// the fresh entry survives the original deadline
|
|
65
|
+
vi.advanceTimersByTime(1000);
|
|
66
|
+
expect(cache.get()).not.toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("set() initializes failStreak to 0; bumpFailStreak counts on the live entry; resetFailStreak restores 0", () => {
|
|
70
|
+
const cache = new CredentialCache();
|
|
71
|
+
cache.set("pw", TTL);
|
|
72
|
+
expect(cache.get()?.failStreak).toBe(0);
|
|
73
|
+
cache.bumpFailStreak();
|
|
74
|
+
cache.bumpFailStreak();
|
|
75
|
+
expect(cache.get()?.failStreak).toBe(2);
|
|
76
|
+
cache.resetFailStreak();
|
|
77
|
+
expect(cache.get()?.failStreak).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("bumpFailStreak/resetFailStreak on an empty cache are no-ops; a fresh set() restarts the streak", () => {
|
|
81
|
+
const cache = new CredentialCache();
|
|
82
|
+
cache.bumpFailStreak();
|
|
83
|
+
cache.resetFailStreak();
|
|
84
|
+
expect(cache.get()).toBeNull();
|
|
85
|
+
cache.set("a", TTL);
|
|
86
|
+
cache.bumpFailStreak();
|
|
87
|
+
expect(cache.get()?.failStreak).toBe(1);
|
|
88
|
+
cache.set("b", TTL); // same cache, new credential — streak belongs to the entry
|
|
89
|
+
expect(cache.get()?.failStreak).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory credential cache for sudo_exec (ADR 0010).
|
|
3
|
+
*
|
|
4
|
+
* A single credential at a time, kept ONLY in memory for the active session —
|
|
5
|
+
* never written to disk or the OS keyring. Expired entries return null from
|
|
6
|
+
* get() (triggering a re-prompt) but are not auto-cleared; clear() is called
|
|
7
|
+
* on session_shutdown, /sudo forget, and auth failure to drop the password
|
|
8
|
+
* from memory entirely. No host/user keying in v1.
|
|
9
|
+
*/
|
|
10
|
+
export interface CachedCredential {
|
|
11
|
+
password: string;
|
|
12
|
+
expiresAt: number;
|
|
13
|
+
/**
|
|
14
|
+
* Consecutive ambiguous command failures (non-terminal credential probe
|
|
15
|
+
* after an unrecognized-signature failure) under THIS entry. The tool's
|
|
16
|
+
* two-strike rule bumps/resets it; a fresh set() restarts it at 0.
|
|
17
|
+
*/
|
|
18
|
+
failStreak: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CredentialCache {
|
|
22
|
+
private entry: CachedCredential | null = null;
|
|
23
|
+
|
|
24
|
+
/** Set (or replace) the cached credential with a fresh TTL (and a zeroed fail streak). */
|
|
25
|
+
set(password: string, ttlMs: number): void {
|
|
26
|
+
this.entry = { password, expiresAt: Date.now() + ttlMs, failStreak: 0 };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Return the cached credential, or null if unset or expired. */
|
|
30
|
+
get(): CachedCredential | null {
|
|
31
|
+
if (this.entry === null) return null;
|
|
32
|
+
if (Date.now() >= this.entry.expiresAt) return null;
|
|
33
|
+
return this.entry;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Drop the credential from memory entirely. */
|
|
37
|
+
clear(): void {
|
|
38
|
+
this.entry = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bump the current entry's failStreak (no-op when the cache is empty). */
|
|
42
|
+
bumpFailStreak(): void {
|
|
43
|
+
if (this.entry !== null) this.entry.failStreak += 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Reset the current entry's failStreak (no-op when the cache is empty). */
|
|
47
|
+
resetFailStreak(): void {
|
|
48
|
+
if (this.entry !== null) this.entry.failStreak = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True when an entry is present but past its expiry. */
|
|
52
|
+
get isExpired(): boolean {
|
|
53
|
+
return this.entry !== null && Date.now() >= this.entry.expiresAt;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Module-level singleton, recreated per session via clear() on session_start (Task 3 lifecycle). */
|
|
58
|
+
export const credentialCache = new CredentialCache();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock core/settings-io BEFORE importing loadSudoConfig — the real module
|
|
4
|
+
// builds its settings path at module load and would read/write the user's
|
|
5
|
+
// actual ~/.pi/agent/settings.json.
|
|
6
|
+
const store = vi.hoisted(() => ({
|
|
7
|
+
settings: {} as Record<string, Record<string, unknown>>,
|
|
8
|
+
saveCalls: [] as Array<{ namespace: string; config: object }>,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@pi-archimedes/core/settings-io", () => ({
|
|
12
|
+
loadConfig: (namespace: string, defaults: Record<string, unknown>) => ({
|
|
13
|
+
...defaults,
|
|
14
|
+
...store.settings[namespace],
|
|
15
|
+
}),
|
|
16
|
+
saveConfig: (namespace: string, config: Record<string, unknown>) => {
|
|
17
|
+
store.saveCalls.push({ namespace, config });
|
|
18
|
+
store.settings[namespace] = config;
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { DEFAULT_SUDO_CONFIG, loadSudoConfig, saveSudoConfig } from "./config.js";
|
|
23
|
+
|
|
24
|
+
describe("sudo config", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
for (const key of Object.keys(store.settings)) delete store.settings[key];
|
|
27
|
+
store.saveCalls.length = 0;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("exposes the shared defaults (15 min TTL, 120 s command timeout)", () => {
|
|
31
|
+
expect(DEFAULT_SUDO_CONFIG).toEqual({ ttlMs: 900000, defaultTimeoutMs: 120000 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns the defaults when no archimedes.sudo section exists", () => {
|
|
35
|
+
expect(loadSudoConfig()).toEqual(DEFAULT_SUDO_CONFIG);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("merges a partial archimedes.sudo section over the defaults", () => {
|
|
39
|
+
store.settings["archimedes.sudo"] = { ttlMs: 5000 };
|
|
40
|
+
expect(loadSudoConfig()).toEqual({ ttlMs: 5000, defaultTimeoutMs: 120000 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("saveSudoConfig writes to the archimedes.sudo namespace", () => {
|
|
44
|
+
saveSudoConfig({ ttlMs: 1234, defaultTimeoutMs: 5678 });
|
|
45
|
+
expect(store.saveCalls).toEqual([{ namespace: "archimedes.sudo", config: { ttlMs: 1234, defaultTimeoutMs: 5678 } }]);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from "@pi-archimedes/core/settings-io";
|
|
2
|
+
|
|
3
|
+
export interface SudoConfig {
|
|
4
|
+
/**
|
|
5
|
+
* Suite-managed by meta's plugin gate (archimedes.sudo.enabled, ADR 0012).
|
|
6
|
+
* sudo NEVER reads this key — when off, meta skips registerSudo entirely.
|
|
7
|
+
* Declared here only so settings editors see the shape.
|
|
8
|
+
*/
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Password cache TTL in ms. Default 15 min. */
|
|
11
|
+
ttlMs: number;
|
|
12
|
+
/** sudo_exec default command timeout in ms. Default 120 s. */
|
|
13
|
+
defaultTimeoutMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_SUDO_CONFIG: SudoConfig = {
|
|
17
|
+
ttlMs: 900_000,
|
|
18
|
+
defaultTimeoutMs: 120_000,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function loadSudoConfig(): SudoConfig {
|
|
22
|
+
return loadConfig("archimedes.sudo", DEFAULT_SUDO_CONFIG);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function saveSudoConfig(config: SudoConfig): void {
|
|
26
|
+
saveConfig("archimedes.sudo", config);
|
|
27
|
+
}
|